@skilljack/mcp
Advanced tools
| /** | ||
| * GitHub configuration parsing and URL detection. | ||
| * Handles detection of GitHub URLs, parsing repo specs, and allowlist validation. | ||
| */ | ||
| /** | ||
| * Parsed GitHub repository specification. | ||
| */ | ||
| export interface GitHubRepoSpec { | ||
| owner: string; | ||
| repo: string; | ||
| ref?: string; | ||
| subpath?: string; | ||
| } | ||
| /** | ||
| * GitHub-specific configuration from environment variables. | ||
| */ | ||
| export interface GitHubConfig { | ||
| token?: string; | ||
| pollIntervalMs: number; | ||
| cacheDir: string; | ||
| allowedOrgs: string[]; | ||
| allowedUsers: string[]; | ||
| } | ||
| /** | ||
| * Check if a path is a GitHub URL. | ||
| * Detects paths containing "github.com". | ||
| */ | ||
| export declare function isGitHubUrl(urlOrPath: string): boolean; | ||
| /** | ||
| * Parse a GitHub URL into a GitHubRepoSpec. | ||
| * | ||
| * Supported formats: | ||
| * github.com/owner/repo | ||
| * github.com/owner/repo@ref | ||
| * github.com/owner/repo/subpath | ||
| * github.com/owner/repo/subpath@ref | ||
| * https://github.com/owner/repo | ||
| * https://github.com/owner/repo.git | ||
| * | ||
| * @param url - The GitHub URL to parse | ||
| * @returns Parsed GitHubRepoSpec | ||
| * @throws Error if URL format is invalid | ||
| */ | ||
| export declare function parseGitHubUrl(url: string): GitHubRepoSpec; | ||
| /** | ||
| * Check if a repository is allowed by the allowlist. | ||
| * If no allowlist is configured (both allowedOrgs and allowedUsers empty), | ||
| * all repos are DENIED by default for security. | ||
| * | ||
| * @param spec - The repository specification | ||
| * @param config - GitHub configuration with allowlists | ||
| * @returns true if allowed, false if blocked | ||
| */ | ||
| export declare function isRepoAllowed(spec: GitHubRepoSpec, config: GitHubConfig): boolean; | ||
| /** | ||
| * Get GitHub configuration from environment variables and config file. | ||
| * Environment variables take precedence over config file. | ||
| * | ||
| * Environment variables: | ||
| * GITHUB_TOKEN - Authentication token for private repos | ||
| * GITHUB_POLL_INTERVAL_MS - Polling interval (0 to disable, default 300000) | ||
| * SKILLJACK_CACHE_DIR - Cache directory (default ~/.skilljack/github-cache) | ||
| * GITHUB_ALLOWED_ORGS - Comma-separated list of allowed organizations (overrides config) | ||
| * GITHUB_ALLOWED_USERS - Comma-separated list of allowed users (overrides config) | ||
| */ | ||
| export declare function getGitHubConfig(): GitHubConfig; | ||
| /** | ||
| * Get the local cache path for a GitHub repository. | ||
| * | ||
| * @param spec - The repository specification | ||
| * @param cacheDir - Base cache directory | ||
| * @returns Full path to the cached repository (including subpath if specified) | ||
| */ | ||
| export declare function getRepoCachePath(spec: GitHubRepoSpec, cacheDir: string): string; | ||
| /** | ||
| * Get the local clone path for a GitHub repository (without subpath). | ||
| * This is where the git repository is cloned to. | ||
| * | ||
| * @param spec - The repository specification | ||
| * @param cacheDir - Base cache directory | ||
| * @returns Full path to the cloned repository root | ||
| */ | ||
| export declare function getRepoClonePath(spec: GitHubRepoSpec, cacheDir: string): string; |
| /** | ||
| * GitHub configuration parsing and URL detection. | ||
| * Handles detection of GitHub URLs, parsing repo specs, and allowlist validation. | ||
| */ | ||
| import * as fs from "node:fs"; | ||
| import * as os from "node:os"; | ||
| import * as path from "node:path"; | ||
| /** | ||
| * Default polling interval: 5 minutes. | ||
| */ | ||
| const DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000; | ||
| /** | ||
| * Default cache directory. | ||
| */ | ||
| const DEFAULT_CACHE_DIR = path.join(os.homedir(), ".skilljack", "github-cache"); | ||
| /** | ||
| * Check if a path is a GitHub URL. | ||
| * Detects paths containing "github.com". | ||
| */ | ||
| export function isGitHubUrl(urlOrPath) { | ||
| return urlOrPath.toLowerCase().includes("github.com"); | ||
| } | ||
| /** | ||
| * Parse a GitHub URL into a GitHubRepoSpec. | ||
| * | ||
| * Supported formats: | ||
| * github.com/owner/repo | ||
| * github.com/owner/repo@ref | ||
| * github.com/owner/repo/subpath | ||
| * github.com/owner/repo/subpath@ref | ||
| * https://github.com/owner/repo | ||
| * https://github.com/owner/repo.git | ||
| * | ||
| * @param url - The GitHub URL to parse | ||
| * @returns Parsed GitHubRepoSpec | ||
| * @throws Error if URL format is invalid | ||
| */ | ||
| export function parseGitHubUrl(url) { | ||
| // Remove protocol prefix if present | ||
| let normalized = url.replace(/^https?:\/\//, ""); | ||
| // Remove github.com prefix | ||
| normalized = normalized.replace(/^github\.com\//i, ""); | ||
| // Remove trailing .git if present | ||
| normalized = normalized.replace(/\.git$/, ""); | ||
| // Extract ref if present (everything after @) | ||
| let ref; | ||
| const atIndex = normalized.lastIndexOf("@"); | ||
| if (atIndex !== -1) { | ||
| ref = normalized.slice(atIndex + 1); | ||
| normalized = normalized.slice(0, atIndex); | ||
| } | ||
| // Split remaining path: owner/repo[/subpath...] | ||
| let parts = normalized.split("/").filter((p) => p.length > 0); | ||
| if (parts.length < 2) { | ||
| throw new Error(`Invalid GitHub URL: "${url}". Expected format: github.com/owner/repo[/subpath][@ref]`); | ||
| } | ||
| const owner = parts[0]; | ||
| const repo = parts[1]; | ||
| // Handle GitHub web URLs with /tree/<ref>/ or /blob/<ref>/ patterns | ||
| // e.g., owner/repo/tree/main/path/to/dir -> extract ref and subpath | ||
| if (parts.length >= 4 && (parts[2] === "tree" || parts[2] === "blob")) { | ||
| // parts[2] is "tree" or "blob", parts[3] is the ref | ||
| if (!ref) { | ||
| ref = parts[3]; | ||
| } | ||
| // Everything after the ref is the subpath | ||
| const subpath = parts.length > 4 ? parts.slice(4).join("/") : undefined; | ||
| return { owner, repo, ref, subpath }; | ||
| } | ||
| const subpath = parts.length > 2 ? parts.slice(2).join("/") : undefined; | ||
| return { owner, repo, ref, subpath }; | ||
| } | ||
| /** | ||
| * Check if a repository is allowed by the allowlist. | ||
| * If no allowlist is configured (both allowedOrgs and allowedUsers empty), | ||
| * all repos are DENIED by default for security. | ||
| * | ||
| * @param spec - The repository specification | ||
| * @param config - GitHub configuration with allowlists | ||
| * @returns true if allowed, false if blocked | ||
| */ | ||
| export function isRepoAllowed(spec, config) { | ||
| // If no allowlist configured, deny all for security | ||
| if (config.allowedOrgs.length === 0 && config.allowedUsers.length === 0) { | ||
| return false; | ||
| } | ||
| const ownerLower = spec.owner.toLowerCase(); | ||
| // Check if owner is in allowed orgs | ||
| if (config.allowedOrgs.some((org) => org.toLowerCase() === ownerLower)) { | ||
| return true; | ||
| } | ||
| // Check if owner is in allowed users | ||
| if (config.allowedUsers.some((user) => user.toLowerCase() === ownerLower)) { | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
| /** | ||
| * Parse a comma-separated list from an environment variable. | ||
| */ | ||
| function parseCommaList(envValue) { | ||
| if (!envValue) { | ||
| return []; | ||
| } | ||
| return envValue | ||
| .split(",") | ||
| .map((s) => s.trim()) | ||
| .filter((s) => s.length > 0); | ||
| } | ||
| /** | ||
| * Path to the config file. | ||
| */ | ||
| const CONFIG_FILE_PATH = path.join(os.homedir(), ".skilljack", "config.json"); | ||
| /** | ||
| * Load allowlist from config file. | ||
| * Returns empty arrays if file doesn't exist or can't be parsed. | ||
| */ | ||
| function loadAllowlistFromConfig() { | ||
| try { | ||
| if (fs.existsSync(CONFIG_FILE_PATH)) { | ||
| const content = fs.readFileSync(CONFIG_FILE_PATH, "utf-8"); | ||
| const config = JSON.parse(content); | ||
| return { | ||
| orgs: Array.isArray(config.githubAllowedOrgs) ? config.githubAllowedOrgs : [], | ||
| users: Array.isArray(config.githubAllowedUsers) ? config.githubAllowedUsers : [], | ||
| }; | ||
| } | ||
| } | ||
| catch { | ||
| // Ignore errors reading config file | ||
| } | ||
| return { orgs: [], users: [] }; | ||
| } | ||
| /** | ||
| * Get GitHub configuration from environment variables and config file. | ||
| * Environment variables take precedence over config file. | ||
| * | ||
| * Environment variables: | ||
| * GITHUB_TOKEN - Authentication token for private repos | ||
| * GITHUB_POLL_INTERVAL_MS - Polling interval (0 to disable, default 300000) | ||
| * SKILLJACK_CACHE_DIR - Cache directory (default ~/.skilljack/github-cache) | ||
| * GITHUB_ALLOWED_ORGS - Comma-separated list of allowed organizations (overrides config) | ||
| * GITHUB_ALLOWED_USERS - Comma-separated list of allowed users (overrides config) | ||
| */ | ||
| export function getGitHubConfig() { | ||
| const pollIntervalStr = process.env.GITHUB_POLL_INTERVAL_MS; | ||
| let pollIntervalMs = DEFAULT_POLL_INTERVAL_MS; | ||
| if (pollIntervalStr !== undefined) { | ||
| const parsed = parseInt(pollIntervalStr, 10); | ||
| if (!isNaN(parsed) && parsed >= 0) { | ||
| pollIntervalMs = parsed; | ||
| } | ||
| } | ||
| // Load allowlist from config file as fallback | ||
| const configAllowlist = loadAllowlistFromConfig(); | ||
| // Environment variables override config file | ||
| const envOrgs = parseCommaList(process.env.GITHUB_ALLOWED_ORGS); | ||
| const envUsers = parseCommaList(process.env.GITHUB_ALLOWED_USERS); | ||
| return { | ||
| token: process.env.GITHUB_TOKEN, | ||
| pollIntervalMs, | ||
| cacheDir: process.env.SKILLJACK_CACHE_DIR || DEFAULT_CACHE_DIR, | ||
| allowedOrgs: envOrgs.length > 0 ? envOrgs : configAllowlist.orgs, | ||
| allowedUsers: envUsers.length > 0 ? envUsers : configAllowlist.users, | ||
| }; | ||
| } | ||
| /** | ||
| * Get the local cache path for a GitHub repository. | ||
| * | ||
| * @param spec - The repository specification | ||
| * @param cacheDir - Base cache directory | ||
| * @returns Full path to the cached repository (including subpath if specified) | ||
| */ | ||
| export function getRepoCachePath(spec, cacheDir) { | ||
| const repoPath = path.join(cacheDir, spec.owner, spec.repo); | ||
| if (spec.subpath) { | ||
| return path.join(repoPath, spec.subpath); | ||
| } | ||
| return repoPath; | ||
| } | ||
| /** | ||
| * Get the local clone path for a GitHub repository (without subpath). | ||
| * This is where the git repository is cloned to. | ||
| * | ||
| * @param spec - The repository specification | ||
| * @param cacheDir - Base cache directory | ||
| * @returns Full path to the cloned repository root | ||
| */ | ||
| export function getRepoClonePath(spec, cacheDir) { | ||
| return path.join(cacheDir, spec.owner, spec.repo); | ||
| } |
| /** | ||
| * GitHub repository polling for updates. | ||
| * Periodically checks for changes and triggers sync when updates are available. | ||
| */ | ||
| import { GitHubRepoSpec } from "./github-config.js"; | ||
| import { SyncOptions, SyncResult } from "./github-sync.js"; | ||
| /** | ||
| * Options for the polling manager. | ||
| */ | ||
| export interface PollingOptions { | ||
| intervalMs: number; | ||
| onUpdate: (spec: GitHubRepoSpec, result: SyncResult) => void; | ||
| onError?: (spec: GitHubRepoSpec, error: Error) => void; | ||
| } | ||
| /** | ||
| * Polling manager interface. | ||
| */ | ||
| export interface PollingManager { | ||
| start(): void; | ||
| stop(): void; | ||
| checkNow(): Promise<void>; | ||
| isRunning(): boolean; | ||
| } | ||
| /** | ||
| * Create a polling manager for GitHub repositories. | ||
| * | ||
| * The manager periodically checks for updates and syncs repositories | ||
| * when changes are detected. Pinned refs (tags, commits) are skipped. | ||
| * | ||
| * @param specs - Repository specifications to poll | ||
| * @param syncOptions - Options for sync operations | ||
| * @param pollingOptions - Polling configuration | ||
| * @returns Polling manager | ||
| */ | ||
| export declare function createPollingManager(specs: GitHubRepoSpec[], syncOptions: SyncOptions, pollingOptions: PollingOptions): PollingManager; |
| /** | ||
| * GitHub repository polling for updates. | ||
| * Periodically checks for changes and triggers sync when updates are available. | ||
| */ | ||
| import { syncRepo, hasRemoteUpdates } from "./github-sync.js"; | ||
| /** | ||
| * Create a polling manager for GitHub repositories. | ||
| * | ||
| * The manager periodically checks for updates and syncs repositories | ||
| * when changes are detected. Pinned refs (tags, commits) are skipped. | ||
| * | ||
| * @param specs - Repository specifications to poll | ||
| * @param syncOptions - Options for sync operations | ||
| * @param pollingOptions - Polling configuration | ||
| * @returns Polling manager | ||
| */ | ||
| export function createPollingManager(specs, syncOptions, pollingOptions) { | ||
| let intervalId = null; | ||
| let isChecking = false; | ||
| /** | ||
| * Filter specs to only include those that should be polled. | ||
| * Pinned refs (tags, commits) are excluded. | ||
| */ | ||
| function getPolledSpecs() { | ||
| return specs.filter((spec) => { | ||
| if (!spec.ref) { | ||
| return true; // No ref means default branch, poll it | ||
| } | ||
| // Exclude what looks like a tag version or commit hash | ||
| const isVersionTag = /^v?\d+(\.\d+)*/.test(spec.ref); | ||
| const isCommitHash = /^[0-9a-f]{7,40}$/i.test(spec.ref); | ||
| return !isVersionTag && !isCommitHash; | ||
| }); | ||
| } | ||
| /** | ||
| * Check all repositories for updates. | ||
| */ | ||
| async function checkForUpdates() { | ||
| if (isChecking) { | ||
| console.error("Polling: Already checking for updates, skipping..."); | ||
| return; | ||
| } | ||
| isChecking = true; | ||
| const polledSpecs = getPolledSpecs(); | ||
| if (polledSpecs.length === 0) { | ||
| console.error("Polling: No repositories to poll (all pinned to specific refs)"); | ||
| isChecking = false; | ||
| return; | ||
| } | ||
| console.error(`Polling: Checking ${polledSpecs.length} repo(s) for updates...`); | ||
| for (const spec of polledSpecs) { | ||
| try { | ||
| const hasUpdates = await hasRemoteUpdates(spec, syncOptions); | ||
| if (hasUpdates) { | ||
| console.error(`Polling: Updates available for ${spec.owner}/${spec.repo}`); | ||
| const result = await syncRepo(spec, syncOptions); | ||
| if (!result.error && result.updated) { | ||
| pollingOptions.onUpdate(spec, result); | ||
| } | ||
| } | ||
| } | ||
| catch (error) { | ||
| const err = error instanceof Error ? error : new Error(String(error)); | ||
| console.error(`Polling: Error checking ${spec.owner}/${spec.repo}: ${err.message}`); | ||
| pollingOptions.onError?.(spec, err); | ||
| } | ||
| } | ||
| isChecking = false; | ||
| } | ||
| return { | ||
| start() { | ||
| if (intervalId !== null) { | ||
| console.error("Polling: Already running"); | ||
| return; | ||
| } | ||
| if (pollingOptions.intervalMs <= 0) { | ||
| console.error("Polling: Disabled (interval <= 0)"); | ||
| return; | ||
| } | ||
| const polledSpecs = getPolledSpecs(); | ||
| if (polledSpecs.length === 0) { | ||
| console.error("Polling: Not starting (all repos pinned to specific refs)"); | ||
| return; | ||
| } | ||
| console.error(`Polling: Starting with ${pollingOptions.intervalMs}ms interval ` + | ||
| `for ${polledSpecs.length} repo(s)`); | ||
| intervalId = setInterval(() => { | ||
| checkForUpdates().catch((error) => { | ||
| console.error(`Polling: Unexpected error: ${error}`); | ||
| }); | ||
| }, pollingOptions.intervalMs); | ||
| }, | ||
| stop() { | ||
| if (intervalId === null) { | ||
| return; | ||
| } | ||
| console.error("Polling: Stopping"); | ||
| clearInterval(intervalId); | ||
| intervalId = null; | ||
| }, | ||
| async checkNow() { | ||
| await checkForUpdates(); | ||
| }, | ||
| isRunning() { | ||
| return intervalId !== null; | ||
| }, | ||
| }; | ||
| } |
| /** | ||
| * GitHub repository synchronization. | ||
| * Handles cloning and pulling repositories to local cache. | ||
| */ | ||
| import { GitHubRepoSpec } from "./github-config.js"; | ||
| /** | ||
| * Options for syncing GitHub repositories. | ||
| */ | ||
| export interface SyncOptions { | ||
| cacheDir: string; | ||
| token?: string; | ||
| shallowClone?: boolean; | ||
| } | ||
| /** | ||
| * Result of a sync operation. | ||
| */ | ||
| export interface SyncResult { | ||
| spec: GitHubRepoSpec; | ||
| localPath: string; | ||
| clonePath: string; | ||
| updated: boolean; | ||
| error?: string; | ||
| } | ||
| /** | ||
| * Sync a single GitHub repository. | ||
| * Clones if not present, pulls if already cloned. | ||
| * | ||
| * @param spec - Repository specification | ||
| * @param options - Sync options | ||
| * @returns Sync result | ||
| */ | ||
| export declare function syncRepo(spec: GitHubRepoSpec, options: SyncOptions): Promise<SyncResult>; | ||
| /** | ||
| * Sync multiple GitHub repositories. | ||
| * | ||
| * @param specs - Repository specifications | ||
| * @param options - Sync options | ||
| * @returns Array of sync results | ||
| */ | ||
| export declare function syncAllRepos(specs: GitHubRepoSpec[], options: SyncOptions): Promise<SyncResult[]>; | ||
| /** | ||
| * Check if a repository has remote updates available. | ||
| * Uses git fetch --dry-run to check without downloading. | ||
| * | ||
| * @param spec - Repository specification | ||
| * @param options - Sync options | ||
| * @returns true if updates are available | ||
| */ | ||
| export declare function hasRemoteUpdates(spec: GitHubRepoSpec, options: SyncOptions): Promise<boolean>; |
| /** | ||
| * GitHub repository synchronization. | ||
| * Handles cloning and pulling repositories to local cache. | ||
| */ | ||
| import * as fs from "node:fs"; | ||
| import * as path from "node:path"; | ||
| import { simpleGit } from "simple-git"; | ||
| import { getRepoClonePath, getRepoCachePath } from "./github-config.js"; | ||
| /** | ||
| * Maximum retry attempts for network operations. | ||
| */ | ||
| const MAX_RETRIES = 3; | ||
| /** | ||
| * Initial backoff delay in milliseconds. | ||
| */ | ||
| const INITIAL_BACKOFF_MS = 1000; | ||
| /** | ||
| * Sleep for a given number of milliseconds. | ||
| */ | ||
| function sleep(ms) { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
| /** | ||
| * Build the HTTPS URL for a GitHub repository. | ||
| * Includes token for authentication if provided. | ||
| */ | ||
| function buildRepoUrl(spec, token) { | ||
| if (token) { | ||
| return `https://${token}@github.com/${spec.owner}/${spec.repo}.git`; | ||
| } | ||
| return `https://github.com/${spec.owner}/${spec.repo}.git`; | ||
| } | ||
| /** | ||
| * Ensure the parent directory exists. | ||
| */ | ||
| function ensureParentDir(filePath) { | ||
| const parentDir = path.dirname(filePath); | ||
| if (!fs.existsSync(parentDir)) { | ||
| fs.mkdirSync(parentDir, { recursive: true }); | ||
| } | ||
| } | ||
| /** | ||
| * Check if a directory is a git repository. | ||
| */ | ||
| function isGitRepo(dir) { | ||
| return fs.existsSync(path.join(dir, ".git")); | ||
| } | ||
| /** | ||
| * Clone a repository with retry logic. | ||
| */ | ||
| async function cloneWithRetry(git, url, destPath, ref, shallowClone) { | ||
| const cloneOptions = []; | ||
| if (shallowClone) { | ||
| cloneOptions.push("--depth", "1"); | ||
| } | ||
| if (ref) { | ||
| cloneOptions.push("--branch", ref); | ||
| } | ||
| for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { | ||
| try { | ||
| await git.clone(url, destPath, cloneOptions); | ||
| return; | ||
| } | ||
| catch (error) { | ||
| const isLastAttempt = attempt === MAX_RETRIES - 1; | ||
| if (isLastAttempt) { | ||
| throw error; | ||
| } | ||
| const backoff = INITIAL_BACKOFF_MS * Math.pow(2, attempt); | ||
| console.error(`Clone attempt ${attempt + 1}/${MAX_RETRIES} failed, retrying in ${backoff}ms...`); | ||
| await sleep(backoff); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Pull updates with retry logic. | ||
| * Returns true if there were changes. | ||
| */ | ||
| async function pullWithRetry(git, ref) { | ||
| for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { | ||
| try { | ||
| // Get current HEAD before pull | ||
| const beforeHead = await git.revparse(["HEAD"]); | ||
| // Pull changes | ||
| if (ref) { | ||
| await git.fetch(["origin", ref]); | ||
| await git.checkout(ref); | ||
| await git.pull("origin", ref, ["--ff-only"]); | ||
| } | ||
| else { | ||
| await git.pull(["--ff-only"]); | ||
| } | ||
| // Check if HEAD changed | ||
| const afterHead = await git.revparse(["HEAD"]); | ||
| return beforeHead !== afterHead; | ||
| } | ||
| catch (error) { | ||
| const isLastAttempt = attempt === MAX_RETRIES - 1; | ||
| if (isLastAttempt) { | ||
| throw error; | ||
| } | ||
| const backoff = INITIAL_BACKOFF_MS * Math.pow(2, attempt); | ||
| console.error(`Pull attempt ${attempt + 1}/${MAX_RETRIES} failed, retrying in ${backoff}ms...`); | ||
| await sleep(backoff); | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
| /** | ||
| * Sync a single GitHub repository. | ||
| * Clones if not present, pulls if already cloned. | ||
| * | ||
| * @param spec - Repository specification | ||
| * @param options - Sync options | ||
| * @returns Sync result | ||
| */ | ||
| export async function syncRepo(spec, options) { | ||
| const clonePath = getRepoClonePath(spec, options.cacheDir); | ||
| const localPath = getRepoCachePath(spec, options.cacheDir); | ||
| const shallowClone = options.shallowClone !== false; // Default true | ||
| const result = { | ||
| spec, | ||
| localPath, | ||
| clonePath, | ||
| updated: false, | ||
| }; | ||
| try { | ||
| const url = buildRepoUrl(spec, options.token); | ||
| const git = simpleGit(); | ||
| if (!isGitRepo(clonePath)) { | ||
| // Clone the repository | ||
| console.error(`Cloning ${spec.owner}/${spec.repo}...`); | ||
| ensureParentDir(clonePath); | ||
| await cloneWithRetry(git, url, clonePath, spec.ref, shallowClone); | ||
| result.updated = true; | ||
| console.error(`Cloned ${spec.owner}/${spec.repo} to ${clonePath}`); | ||
| } | ||
| else { | ||
| // Pull updates | ||
| console.error(`Pulling updates for ${spec.owner}/${spec.repo}...`); | ||
| const repoGit = simpleGit(clonePath); | ||
| // For pinned refs (tags/commits), we don't pull updates | ||
| if (spec.ref && !spec.ref.includes("/")) { | ||
| // Check if this looks like a tag or commit hash | ||
| const isTag = await repoGit | ||
| .tags() | ||
| .then((tags) => tags.all.includes(spec.ref)) | ||
| .catch(() => false); | ||
| const isCommit = /^[0-9a-f]{7,40}$/i.test(spec.ref); | ||
| if (isTag || isCommit) { | ||
| console.error(`Skipping pull for pinned ref: ${spec.ref}`); | ||
| return result; | ||
| } | ||
| } | ||
| result.updated = await pullWithRetry(repoGit, spec.ref); | ||
| if (result.updated) { | ||
| console.error(`Updated ${spec.owner}/${spec.repo}`); | ||
| } | ||
| else { | ||
| console.error(`${spec.owner}/${spec.repo} is up to date`); | ||
| } | ||
| } | ||
| // Verify the subpath exists if specified | ||
| if (spec.subpath && !fs.existsSync(localPath)) { | ||
| result.error = `Subpath "${spec.subpath}" not found in repository`; | ||
| console.error(`Warning: ${result.error}`); | ||
| } | ||
| } | ||
| catch (error) { | ||
| const errorMessage = error instanceof Error ? error.message : String(error); | ||
| // Provide helpful error messages | ||
| if (errorMessage.includes("Authentication failed") || errorMessage.includes("403")) { | ||
| result.error = `Authentication failed for ${spec.owner}/${spec.repo}. For private repos, set GITHUB_TOKEN environment variable.`; | ||
| } | ||
| else if (errorMessage.includes("rate limit")) { | ||
| result.error = `Rate limited when accessing ${spec.owner}/${spec.repo}. Try again later.`; | ||
| } | ||
| else if (errorMessage.includes("not found") || errorMessage.includes("404")) { | ||
| result.error = `Repository not found: ${spec.owner}/${spec.repo}`; | ||
| } | ||
| else { | ||
| result.error = `Failed to sync ${spec.owner}/${spec.repo}: ${errorMessage}`; | ||
| } | ||
| console.error(result.error); | ||
| } | ||
| return result; | ||
| } | ||
| /** | ||
| * Sync multiple GitHub repositories. | ||
| * | ||
| * @param specs - Repository specifications | ||
| * @param options - Sync options | ||
| * @returns Array of sync results | ||
| */ | ||
| export async function syncAllRepos(specs, options) { | ||
| const results = []; | ||
| for (const spec of specs) { | ||
| const result = await syncRepo(spec, options); | ||
| results.push(result); | ||
| } | ||
| return results; | ||
| } | ||
| /** | ||
| * Check if a repository has remote updates available. | ||
| * Uses git fetch --dry-run to check without downloading. | ||
| * | ||
| * @param spec - Repository specification | ||
| * @param options - Sync options | ||
| * @returns true if updates are available | ||
| */ | ||
| export async function hasRemoteUpdates(spec, options) { | ||
| const clonePath = getRepoClonePath(spec, options.cacheDir); | ||
| if (!isGitRepo(clonePath)) { | ||
| // Not cloned yet, so yes there are "updates" | ||
| return true; | ||
| } | ||
| // Skip check for pinned refs | ||
| if (spec.ref) { | ||
| const git = simpleGit(clonePath); | ||
| const isTag = await git | ||
| .tags() | ||
| .then((tags) => tags.all.includes(spec.ref)) | ||
| .catch(() => false); | ||
| const isCommit = /^[0-9a-f]{7,40}$/i.test(spec.ref); | ||
| if (isTag || isCommit) { | ||
| return false; | ||
| } | ||
| } | ||
| try { | ||
| const git = simpleGit(clonePath); | ||
| const url = buildRepoUrl(spec, options.token); | ||
| // Fetch to update remote refs | ||
| await git.fetch(["origin"]); | ||
| // Compare local and remote | ||
| const localHead = await git.revparse(["HEAD"]); | ||
| const remoteRef = spec.ref ? `origin/${spec.ref}` : "origin/HEAD"; | ||
| try { | ||
| const remoteHead = await git.revparse([remoteRef]); | ||
| return localHead !== remoteHead; | ||
| } | ||
| catch { | ||
| // Remote ref might not exist, try origin/main or origin/master | ||
| for (const branch of ["origin/main", "origin/master"]) { | ||
| try { | ||
| const remoteHead = await git.revparse([branch]); | ||
| return localHead !== remoteHead; | ||
| } | ||
| catch { | ||
| continue; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
| } | ||
| catch (error) { | ||
| console.error(`Failed to check for updates: ${error}`); | ||
| return false; | ||
| } | ||
| } |
| /** | ||
| * MCP App tool registration for skill directory configuration. | ||
| * | ||
| * Registers: | ||
| * - skill-config: Opens the configuration UI | ||
| * - skill-config-add-directory: Adds a directory (UI-only) | ||
| * - skill-config-remove-directory: Removes a directory (UI-only) | ||
| */ | ||
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { SkillState } from "./skill-tool.js"; | ||
| /** | ||
| * Callback type for when directories or GitHub settings change. | ||
| */ | ||
| export type OnDirectoriesChangedCallback = () => void | Promise<void>; | ||
| /** | ||
| * Register skill-config MCP App tools and resource. | ||
| * | ||
| * @param server - The MCP server instance | ||
| * @param skillState - Shared skill state for getting skill counts | ||
| * @param onDirectoriesChanged - Callback when directories are added/removed | ||
| */ | ||
| export declare function registerSkillConfigTool(server: McpServer, skillState: SkillState, onDirectoriesChanged: OnDirectoriesChangedCallback): void; |
| /** | ||
| * MCP App tool registration for skill directory configuration. | ||
| * | ||
| * Registers: | ||
| * - skill-config: Opens the configuration UI | ||
| * - skill-config-add-directory: Adds a directory (UI-only) | ||
| * - skill-config-remove-directory: Removes a directory (UI-only) | ||
| */ | ||
| import * as fs from "node:fs"; | ||
| import * as fsPromises from "node:fs/promises"; | ||
| import * as path from "node:path"; | ||
| import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; | ||
| import { z } from "zod"; | ||
| import { getAllDirectoriesWithSources, getConfigState, addDirectoryToConfig, removeDirectoryFromConfig, getGitHubAllowedOrgs, getGitHubAllowedUsers, addGitHubAllowedOrg, removeGitHubAllowedOrg, getStaticModeFromConfig, setStaticModeInConfig, } from "./skill-config.js"; | ||
| import { isGitHubUrl, parseGitHubUrl } from "./github-config.js"; | ||
| /** | ||
| * Resource URI for the skill-config UI. | ||
| */ | ||
| const RESOURCE_URI = "ui://skill-config/mcp-app.html"; | ||
| /** | ||
| * Get the path to the bundled UI HTML file. | ||
| */ | ||
| function getUIPath() { | ||
| // In production (dist/), the UI is at dist/ui/mcp-app.html | ||
| // In development, check multiple locations | ||
| const possiblePaths = [ | ||
| // From dist/skill-config-tool.js | ||
| path.join(import.meta.dirname, "ui", "mcp-app.html"), | ||
| // From src/ during development | ||
| path.join(import.meta.dirname, "..", "dist", "ui", "mcp-app.html"), | ||
| ]; | ||
| for (const p of possiblePaths) { | ||
| if (fs.existsSync(p)) { | ||
| return p; | ||
| } | ||
| } | ||
| throw new Error(`UI file not found. Tried: ${possiblePaths.join(", ")}. ` + | ||
| "Run 'npm run build:ui' to build the UI."); | ||
| } | ||
| /** | ||
| * Schema shape for add-directory tool input. | ||
| */ | ||
| const AddDirectoryInputSchema = { | ||
| directory: z.string().describe("Absolute path to the skills directory to add"), | ||
| }; | ||
| /** | ||
| * Schema shape for remove-directory tool input. | ||
| */ | ||
| const RemoveDirectoryInputSchema = { | ||
| directory: z.string().describe("Absolute path to the skills directory to remove"), | ||
| }; | ||
| /** | ||
| * Register skill-config MCP App tools and resource. | ||
| * | ||
| * @param server - The MCP server instance | ||
| * @param skillState - Shared skill state for getting skill counts | ||
| * @param onDirectoriesChanged - Callback when directories are added/removed | ||
| */ | ||
| export function registerSkillConfigTool(server, skillState, onDirectoriesChanged) { | ||
| /** | ||
| * Get directory info with skill counts from skillState. | ||
| */ | ||
| function getDirectoriesWithCounts() { | ||
| const dirs = getAllDirectoriesWithSources(); | ||
| // Count skills per directory | ||
| for (const dir of dirs) { | ||
| let count = 0; | ||
| if (isGitHubUrl(dir.path)) { | ||
| // For GitHub directories, match by owner/repo from skill source | ||
| try { | ||
| const spec = parseGitHubUrl(dir.path); | ||
| for (const skill of skillState.skillMap.values()) { | ||
| if (skill.source.type === "github" && | ||
| skill.source.owner?.toLowerCase() === spec.owner.toLowerCase() && | ||
| skill.source.repo?.toLowerCase() === spec.repo.toLowerCase()) { | ||
| count++; | ||
| } | ||
| } | ||
| } | ||
| catch { | ||
| // Invalid GitHub URL, count stays 0 | ||
| } | ||
| } | ||
| else { | ||
| // For local directories, match by path prefix | ||
| for (const skill of skillState.skillMap.values()) { | ||
| const skillDir = path.dirname(skill.path); | ||
| if (skillDir.startsWith(dir.path)) { | ||
| count++; | ||
| } | ||
| } | ||
| } | ||
| dir.skillCount = count; | ||
| } | ||
| return dirs; | ||
| } | ||
| // Main config tool - opens UI | ||
| registerAppTool(server, "skill-config", { | ||
| title: "Configure Skills", | ||
| description: "Open the skills directory configuration UI. " + | ||
| "Use when user wants to configure, add, or remove skill directories.", | ||
| inputSchema: {}, | ||
| outputSchema: { | ||
| directories: z.array(z.object({ | ||
| path: z.string(), | ||
| source: z.string(), | ||
| type: z.string(), | ||
| valid: z.boolean(), | ||
| allowed: z.boolean(), | ||
| skillCount: z.number().optional(), | ||
| })), | ||
| activeSource: z.string(), | ||
| isOverridden: z.boolean(), | ||
| staticMode: z.boolean(), | ||
| allowedOrgs: z.array(z.string()), | ||
| allowedUsers: z.array(z.string()), | ||
| }, | ||
| _meta: { ui: { resourceUri: RESOURCE_URI } }, | ||
| annotations: { | ||
| readOnlyHint: true, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: false, | ||
| }, | ||
| }, async () => { | ||
| const configState = getConfigState(); | ||
| const directories = getDirectoriesWithCounts(); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: "Skills configuration UI opened.", | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| directories, | ||
| activeSource: configState.activeSource, | ||
| isOverridden: configState.isOverridden, | ||
| staticMode: getStaticModeFromConfig(), | ||
| allowedOrgs: getGitHubAllowedOrgs(), | ||
| allowedUsers: getGitHubAllowedUsers(), | ||
| }, | ||
| }; | ||
| }); | ||
| // Add directory tool (UI-only, hidden from model) | ||
| registerAppTool(server, "skill-config-add-directory", { | ||
| title: "Add Skills Directory", | ||
| description: "Add a skills directory to the configuration.", | ||
| inputSchema: AddDirectoryInputSchema, | ||
| outputSchema: { | ||
| success: z.boolean(), | ||
| directories: z.array(z.object({ | ||
| path: z.string(), | ||
| source: z.string(), | ||
| type: z.string(), | ||
| valid: z.boolean(), | ||
| allowed: z.boolean(), | ||
| skillCount: z.number().optional(), | ||
| })).optional(), | ||
| activeSource: z.string().optional(), | ||
| isOverridden: z.boolean().optional(), | ||
| error: z.string().optional(), | ||
| }, | ||
| _meta: { | ||
| ui: { | ||
| resourceUri: RESOURCE_URI, | ||
| visibility: ["app"], // Hidden from model, UI can call it | ||
| }, | ||
| }, | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: false, | ||
| idempotentHint: false, | ||
| openWorldHint: false, | ||
| }, | ||
| }, async (args) => { | ||
| const { directory } = args; | ||
| try { | ||
| addDirectoryToConfig(directory); | ||
| onDirectoriesChanged(); | ||
| const directories = getDirectoriesWithCounts(); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Added directory: ${directory}`, | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| success: true, | ||
| directories, | ||
| activeSource: getConfigState().activeSource, | ||
| isOverridden: getConfigState().isOverridden, | ||
| staticMode: getStaticModeFromConfig(), | ||
| allowedOrgs: getGitHubAllowedOrgs(), | ||
| allowedUsers: getGitHubAllowedUsers(), | ||
| }, | ||
| }; | ||
| } | ||
| catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Failed to add directory: ${message}`, | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| success: false, | ||
| error: message, | ||
| }, | ||
| isError: true, | ||
| }; | ||
| } | ||
| }); | ||
| // Remove directory tool (UI-only, hidden from model) | ||
| registerAppTool(server, "skill-config-remove-directory", { | ||
| title: "Remove Skills Directory", | ||
| description: "Remove a skills directory from the configuration.", | ||
| inputSchema: RemoveDirectoryInputSchema, | ||
| outputSchema: { | ||
| success: z.boolean(), | ||
| directories: z.array(z.object({ | ||
| path: z.string(), | ||
| source: z.string(), | ||
| type: z.string(), | ||
| valid: z.boolean(), | ||
| allowed: z.boolean(), | ||
| skillCount: z.number().optional(), | ||
| })).optional(), | ||
| activeSource: z.string().optional(), | ||
| isOverridden: z.boolean().optional(), | ||
| error: z.string().optional(), | ||
| }, | ||
| _meta: { | ||
| ui: { | ||
| resourceUri: RESOURCE_URI, | ||
| visibility: ["app"], // Hidden from model, UI can call it | ||
| }, | ||
| }, | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: true, | ||
| idempotentHint: true, | ||
| openWorldHint: false, | ||
| }, | ||
| }, async (args) => { | ||
| const { directory } = args; | ||
| try { | ||
| removeDirectoryFromConfig(directory); | ||
| onDirectoriesChanged(); | ||
| const directories = getDirectoriesWithCounts(); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Removed directory: ${directory}`, | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| success: true, | ||
| directories, | ||
| activeSource: getConfigState().activeSource, | ||
| isOverridden: getConfigState().isOverridden, | ||
| staticMode: getStaticModeFromConfig(), | ||
| allowedOrgs: getGitHubAllowedOrgs(), | ||
| allowedUsers: getGitHubAllowedUsers(), | ||
| }, | ||
| }; | ||
| } | ||
| catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Failed to remove directory: ${message}`, | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| success: false, | ||
| error: message, | ||
| }, | ||
| isError: true, | ||
| }; | ||
| } | ||
| }); | ||
| // Add allowed org tool (UI-only) | ||
| registerAppTool(server, "skill-config-add-allowed-org", { | ||
| title: "Add Allowed GitHub Org", | ||
| description: "Add a GitHub organization to the allowed list for skill repos.", | ||
| inputSchema: { | ||
| org: z.string().describe("GitHub organization name to allow"), | ||
| }, | ||
| outputSchema: { | ||
| success: z.boolean(), | ||
| allowedOrgs: z.array(z.string()), | ||
| error: z.string().optional(), | ||
| }, | ||
| _meta: { | ||
| ui: { | ||
| resourceUri: RESOURCE_URI, | ||
| visibility: ["app"], | ||
| }, | ||
| }, | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: false, | ||
| }, | ||
| }, async (args) => { | ||
| const { org } = args; | ||
| try { | ||
| addGitHubAllowedOrg(org); | ||
| onDirectoriesChanged(); // Trigger GitHub resync | ||
| // Return full state so UI can update directories' allowed status | ||
| const directories = getDirectoriesWithCounts(); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Added allowed org: ${org}`, | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| success: true, | ||
| directories, | ||
| activeSource: getConfigState().activeSource, | ||
| isOverridden: getConfigState().isOverridden, | ||
| staticMode: getStaticModeFromConfig(), | ||
| allowedOrgs: getGitHubAllowedOrgs(), | ||
| allowedUsers: getGitHubAllowedUsers(), | ||
| }, | ||
| }; | ||
| } | ||
| catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Failed to add allowed org: ${message}`, | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| success: false, | ||
| allowedOrgs: getGitHubAllowedOrgs(), | ||
| error: message, | ||
| }, | ||
| isError: true, | ||
| }; | ||
| } | ||
| }); | ||
| // Remove allowed org tool (UI-only) | ||
| registerAppTool(server, "skill-config-remove-allowed-org", { | ||
| title: "Remove Allowed GitHub Org", | ||
| description: "Remove a GitHub organization from the allowed list.", | ||
| inputSchema: { | ||
| org: z.string().describe("GitHub organization name to remove"), | ||
| }, | ||
| outputSchema: { | ||
| success: z.boolean(), | ||
| allowedOrgs: z.array(z.string()), | ||
| error: z.string().optional(), | ||
| }, | ||
| _meta: { | ||
| ui: { | ||
| resourceUri: RESOURCE_URI, | ||
| visibility: ["app"], | ||
| }, | ||
| }, | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: true, | ||
| idempotentHint: true, | ||
| openWorldHint: false, | ||
| }, | ||
| }, async (args) => { | ||
| const { org } = args; | ||
| try { | ||
| removeGitHubAllowedOrg(org); | ||
| onDirectoriesChanged(); // Trigger GitHub resync | ||
| // Return full state so UI can update directories' allowed status | ||
| const directories = getDirectoriesWithCounts(); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Removed allowed org: ${org}`, | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| success: true, | ||
| directories, | ||
| activeSource: getConfigState().activeSource, | ||
| isOverridden: getConfigState().isOverridden, | ||
| staticMode: getStaticModeFromConfig(), | ||
| allowedOrgs: getGitHubAllowedOrgs(), | ||
| allowedUsers: getGitHubAllowedUsers(), | ||
| }, | ||
| }; | ||
| } | ||
| catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Failed to remove allowed org: ${message}`, | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| success: false, | ||
| allowedOrgs: getGitHubAllowedOrgs(), | ||
| error: message, | ||
| }, | ||
| isError: true, | ||
| }; | ||
| } | ||
| }); | ||
| // Set static mode tool (UI-only, hidden from model) | ||
| registerAppTool(server, "skill-config-set-static-mode", { | ||
| title: "Set Static Mode", | ||
| description: "Enable or disable static mode (freezes skills list at startup).", | ||
| inputSchema: { | ||
| enabled: z.boolean().describe("Whether to enable static mode"), | ||
| }, | ||
| outputSchema: { | ||
| success: z.boolean(), | ||
| staticMode: z.boolean().optional(), | ||
| error: z.string().optional(), | ||
| }, | ||
| _meta: { | ||
| ui: { | ||
| resourceUri: RESOURCE_URI, | ||
| visibility: ["app"], // Hidden from model, UI can call it | ||
| }, | ||
| }, | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: false, | ||
| }, | ||
| }, async (args) => { | ||
| const { enabled } = args; | ||
| try { | ||
| setStaticModeInConfig(enabled); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Static mode ${enabled ? "enabled" : "disabled"}. Restart server for changes to take effect.`, | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| success: true, | ||
| staticMode: enabled, | ||
| }, | ||
| }; | ||
| } | ||
| catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Failed to set static mode: ${message}`, | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| success: false, | ||
| error: message, | ||
| }, | ||
| isError: true, | ||
| }; | ||
| } | ||
| }); | ||
| // Register the HTML UI resource | ||
| registerAppResource(server, RESOURCE_URI, RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => { | ||
| const uiPath = getUIPath(); | ||
| const html = await fsPromises.readFile(uiPath, "utf-8"); | ||
| return { | ||
| contents: [ | ||
| { | ||
| uri: RESOURCE_URI, | ||
| mimeType: RESOURCE_MIME_TYPE, | ||
| text: html, | ||
| }, | ||
| ], | ||
| }; | ||
| }); | ||
| } |
| /** | ||
| * Configuration management for skill directories. | ||
| * | ||
| * Handles loading/saving skill directory configuration from: | ||
| * 1. CLI args (highest priority) | ||
| * 2. SKILLS_DIR environment variable | ||
| * 3. Config file (~/.skilljack/config.json) | ||
| * | ||
| * Supports both local directories and GitHub repository URLs. | ||
| */ | ||
| /** | ||
| * Invocation settings that can be overridden per skill. | ||
| */ | ||
| export interface SkillInvocationOverrides { | ||
| assistant?: boolean; | ||
| user?: boolean; | ||
| } | ||
| /** | ||
| * Configuration file schema. | ||
| */ | ||
| export interface SkillConfig { | ||
| skillDirectories: string[]; | ||
| staticMode?: boolean; | ||
| skillInvocationOverrides?: Record<string, SkillInvocationOverrides>; | ||
| githubAllowedOrgs?: string[]; | ||
| githubAllowedUsers?: string[]; | ||
| } | ||
| /** | ||
| * Source of a skill directory configuration. | ||
| */ | ||
| export type DirectorySource = "cli" | "env" | "config"; | ||
| /** | ||
| * Type of skill source (local directory or GitHub repo). | ||
| */ | ||
| export type SourceType = "local" | "github"; | ||
| /** | ||
| * A skill directory with its source information. | ||
| */ | ||
| export interface DirectoryInfo { | ||
| path: string; | ||
| source: DirectorySource; | ||
| type: SourceType; | ||
| skillCount: number; | ||
| valid: boolean; | ||
| allowed: boolean; | ||
| } | ||
| /** | ||
| * Configuration state tracking active directories and their sources. | ||
| */ | ||
| export interface ConfigState { | ||
| /** All active directories with source info */ | ||
| directories: DirectoryInfo[]; | ||
| /** Which source is currently providing directories (cli > env > config) */ | ||
| activeSource: DirectorySource; | ||
| /** Whether directories are overridden by CLI or env (config file edits won't take effect) */ | ||
| isOverridden: boolean; | ||
| } | ||
| /** | ||
| * Get the platform-appropriate config directory path. | ||
| * Returns ~/.skilljack on Unix, %USERPROFILE%\.skilljack on Windows. | ||
| */ | ||
| export declare function getConfigDir(): string; | ||
| /** | ||
| * Get the full path to the config file. | ||
| */ | ||
| export declare function getConfigPath(): string; | ||
| /** | ||
| * Load config from the config file. | ||
| * Returns empty config if file doesn't exist. | ||
| */ | ||
| export declare function loadConfigFile(): SkillConfig; | ||
| /** | ||
| * Save config to the config file. | ||
| */ | ||
| export declare function saveConfigFile(config: SkillConfig): void; | ||
| /** | ||
| * Parse CLI arguments for skill directories. | ||
| * Returns resolved absolute paths for local dirs, unchanged for GitHub URLs. | ||
| */ | ||
| export declare function parseCLIArgs(): string[]; | ||
| /** | ||
| * Parse SKILLS_DIR environment variable. | ||
| * Returns resolved absolute paths for local dirs, unchanged for GitHub URLs. | ||
| */ | ||
| export declare function parseEnvVar(): string[]; | ||
| /** | ||
| * Get all skill directories with their source information. | ||
| * Priority: CLI args > env var > config file | ||
| */ | ||
| export declare function getConfigState(): ConfigState; | ||
| /** | ||
| * Get skill directories from all sources combined. | ||
| * Used for the UI to show all configured directories. | ||
| */ | ||
| export declare function getAllDirectoriesWithSources(): DirectoryInfo[]; | ||
| /** | ||
| * Add a directory or GitHub URL to the config file. | ||
| * Does not affect CLI or env var configurations. | ||
| */ | ||
| export declare function addDirectoryToConfig(directory: string): void; | ||
| /** | ||
| * Remove a directory or GitHub URL from the config file. | ||
| * Only removes from config file, not CLI or env var. | ||
| */ | ||
| export declare function removeDirectoryFromConfig(directory: string): void; | ||
| /** | ||
| * Get only the active skill directories (respecting priority). | ||
| * This is what the server should use for skill discovery. | ||
| */ | ||
| export declare function getActiveDirectories(): string[]; | ||
| /** | ||
| * Get static mode setting from config file. | ||
| */ | ||
| export declare function getStaticModeFromConfig(): boolean; | ||
| /** | ||
| * Set static mode setting in config file. | ||
| */ | ||
| export declare function setStaticModeInConfig(enabled: boolean): void; | ||
| /** | ||
| * Get all skill invocation overrides from the config file. | ||
| */ | ||
| export declare function getSkillInvocationOverrides(): Record<string, SkillInvocationOverrides>; | ||
| /** | ||
| * Set an invocation override for a skill. | ||
| * @param skillName - The name of the skill | ||
| * @param setting - Which setting to override ("assistant" or "user") | ||
| * @param value - The new value for the setting | ||
| */ | ||
| export declare function setSkillInvocationOverride(skillName: string, setting: "assistant" | "user", value: boolean): void; | ||
| /** | ||
| * Clear an invocation override for a skill (revert to frontmatter default). | ||
| * @param skillName - The name of the skill | ||
| * @param setting - Which setting to clear (omit to clear both) | ||
| */ | ||
| export declare function clearSkillInvocationOverride(skillName: string, setting?: "assistant" | "user"): void; | ||
| /** | ||
| * Get the GitHub allowed orgs from config file. | ||
| */ | ||
| export declare function getGitHubAllowedOrgs(): string[]; | ||
| /** | ||
| * Get the GitHub allowed users from config file. | ||
| */ | ||
| export declare function getGitHubAllowedUsers(): string[]; | ||
| /** | ||
| * Add a GitHub org to the allowed list. | ||
| * @param org - The org name to allow | ||
| */ | ||
| export declare function addGitHubAllowedOrg(org: string): void; | ||
| /** | ||
| * Remove a GitHub org from the allowed list. | ||
| * @param org - The org name to remove | ||
| */ | ||
| export declare function removeGitHubAllowedOrg(org: string): void; | ||
| /** | ||
| * Add a GitHub user to the allowed list. | ||
| * @param user - The user name to allow | ||
| */ | ||
| export declare function addGitHubAllowedUser(user: string): void; | ||
| /** | ||
| * Remove a GitHub user from the allowed list. | ||
| * @param user - The user name to remove | ||
| */ | ||
| export declare function removeGitHubAllowedUser(user: string): void; |
| /** | ||
| * Configuration management for skill directories. | ||
| * | ||
| * Handles loading/saving skill directory configuration from: | ||
| * 1. CLI args (highest priority) | ||
| * 2. SKILLS_DIR environment variable | ||
| * 3. Config file (~/.skilljack/config.json) | ||
| * | ||
| * Supports both local directories and GitHub repository URLs. | ||
| */ | ||
| import * as fs from "node:fs"; | ||
| import * as path from "node:path"; | ||
| import * as os from "node:os"; | ||
| import { isGitHubUrl, parseGitHubUrl, isRepoAllowed, getGitHubConfig } from "./github-config.js"; | ||
| /** | ||
| * Check if a path is valid (exists for local, always valid for GitHub). | ||
| */ | ||
| function isValidPath(p) { | ||
| if (isGitHubUrl(p)) { | ||
| return true; // GitHub URLs are validated during sync | ||
| } | ||
| return fs.existsSync(p); | ||
| } | ||
| /** | ||
| * Get the source type for a path. | ||
| */ | ||
| function getSourceType(p) { | ||
| return isGitHubUrl(p) ? "github" : "local"; | ||
| } | ||
| /** | ||
| * Check if a path is allowed (local paths are always allowed, | ||
| * GitHub URLs must have their org/user in the allowlist). | ||
| */ | ||
| function isPathAllowed(p) { | ||
| if (!isGitHubUrl(p)) { | ||
| return true; // Local paths are always allowed | ||
| } | ||
| try { | ||
| const spec = parseGitHubUrl(p); | ||
| const config = getGitHubConfig(); | ||
| return isRepoAllowed(spec, config); | ||
| } | ||
| catch { | ||
| return false; // Invalid GitHub URL | ||
| } | ||
| } | ||
| /** | ||
| * Separator for multiple paths in SKILLS_DIR environment variable. | ||
| */ | ||
| const PATH_LIST_SEPARATOR = ","; | ||
| /** | ||
| * Get the platform-appropriate config directory path. | ||
| * Returns ~/.skilljack on Unix, %USERPROFILE%\.skilljack on Windows. | ||
| */ | ||
| export function getConfigDir() { | ||
| return path.join(os.homedir(), ".skilljack"); | ||
| } | ||
| /** | ||
| * Get the full path to the config file. | ||
| */ | ||
| export function getConfigPath() { | ||
| return path.join(getConfigDir(), "config.json"); | ||
| } | ||
| /** | ||
| * Ensure the config directory exists. | ||
| */ | ||
| function ensureConfigDir() { | ||
| const configDir = getConfigDir(); | ||
| if (!fs.existsSync(configDir)) { | ||
| fs.mkdirSync(configDir, { recursive: true }); | ||
| } | ||
| } | ||
| /** | ||
| * Load config from the config file. | ||
| * Returns empty config if file doesn't exist. | ||
| */ | ||
| export function loadConfigFile() { | ||
| const configPath = getConfigPath(); | ||
| if (!fs.existsSync(configPath)) { | ||
| return { skillDirectories: [], skillInvocationOverrides: {} }; | ||
| } | ||
| try { | ||
| const content = fs.readFileSync(configPath, "utf-8"); | ||
| const parsed = JSON.parse(content); | ||
| // Validate and normalize directories (handle both local paths and GitHub URLs) | ||
| const skillDirectories = Array.isArray(parsed.skillDirectories) | ||
| ? parsed.skillDirectories | ||
| .filter((p) => typeof p === "string") | ||
| .map((p) => isGitHubUrl(p) ? p : path.resolve(p)) | ||
| : []; | ||
| // Validate and normalize invocation overrides | ||
| const skillInvocationOverrides = {}; | ||
| if (parsed.skillInvocationOverrides && typeof parsed.skillInvocationOverrides === "object") { | ||
| for (const [name, override] of Object.entries(parsed.skillInvocationOverrides)) { | ||
| if (typeof override === "object" && override !== null) { | ||
| const validOverride = {}; | ||
| const o = override; | ||
| if (typeof o.assistant === "boolean") | ||
| validOverride.assistant = o.assistant; | ||
| if (typeof o.user === "boolean") | ||
| validOverride.user = o.user; | ||
| if (Object.keys(validOverride).length > 0) { | ||
| skillInvocationOverrides[name] = validOverride; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return { | ||
| skillDirectories, | ||
| skillInvocationOverrides, | ||
| staticMode: parsed.staticMode === true, | ||
| githubAllowedOrgs: Array.isArray(parsed.githubAllowedOrgs) ? parsed.githubAllowedOrgs : [], | ||
| githubAllowedUsers: Array.isArray(parsed.githubAllowedUsers) ? parsed.githubAllowedUsers : [], | ||
| }; | ||
| } | ||
| catch (error) { | ||
| console.error(`Warning: Failed to parse config file: ${error}`); | ||
| return { skillDirectories: [], skillInvocationOverrides: {} }; | ||
| } | ||
| } | ||
| /** | ||
| * Save config to the config file. | ||
| */ | ||
| export function saveConfigFile(config) { | ||
| ensureConfigDir(); | ||
| const configPath = getConfigPath(); | ||
| try { | ||
| fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); | ||
| } | ||
| catch (error) { | ||
| throw new Error(`Failed to save config file: ${error}`); | ||
| } | ||
| } | ||
| /** | ||
| * Parse CLI arguments for skill directories. | ||
| * Returns resolved absolute paths for local dirs, unchanged for GitHub URLs. | ||
| */ | ||
| export function parseCLIArgs() { | ||
| const dirs = []; | ||
| const args = process.argv.slice(2); | ||
| for (const arg of args) { | ||
| if (!arg.startsWith("-")) { | ||
| const paths = arg | ||
| .split(PATH_LIST_SEPARATOR) | ||
| .map((p) => p.trim()) | ||
| .filter((p) => p.length > 0) | ||
| .map((p) => isGitHubUrl(p) ? p : path.resolve(p)); | ||
| dirs.push(...paths); | ||
| } | ||
| } | ||
| return [...new Set(dirs)]; // Deduplicate | ||
| } | ||
| /** | ||
| * Parse SKILLS_DIR environment variable. | ||
| * Returns resolved absolute paths for local dirs, unchanged for GitHub URLs. | ||
| */ | ||
| export function parseEnvVar() { | ||
| const envDir = process.env.SKILLS_DIR; | ||
| if (!envDir) { | ||
| return []; | ||
| } | ||
| const dirs = envDir | ||
| .split(PATH_LIST_SEPARATOR) | ||
| .map((p) => p.trim()) | ||
| .filter((p) => p.length > 0) | ||
| .map((p) => isGitHubUrl(p) ? p : path.resolve(p)); | ||
| return [...new Set(dirs)]; // Deduplicate | ||
| } | ||
| /** | ||
| * Get all skill directories with their source information. | ||
| * Priority: CLI args > env var > config file | ||
| */ | ||
| export function getConfigState() { | ||
| // Check CLI args first | ||
| const cliDirs = parseCLIArgs(); | ||
| if (cliDirs.length > 0) { | ||
| return { | ||
| directories: cliDirs.map((p) => ({ | ||
| path: p, | ||
| source: "cli", | ||
| type: getSourceType(p), | ||
| skillCount: 0, // Will be filled in by caller | ||
| valid: isValidPath(p), | ||
| allowed: isPathAllowed(p), | ||
| })), | ||
| activeSource: "cli", | ||
| isOverridden: true, | ||
| }; | ||
| } | ||
| // Check env var next | ||
| const envDirs = parseEnvVar(); | ||
| if (envDirs.length > 0) { | ||
| return { | ||
| directories: envDirs.map((p) => ({ | ||
| path: p, | ||
| source: "env", | ||
| type: getSourceType(p), | ||
| skillCount: 0, | ||
| valid: isValidPath(p), | ||
| allowed: isPathAllowed(p), | ||
| })), | ||
| activeSource: "env", | ||
| isOverridden: true, | ||
| }; | ||
| } | ||
| // Fall back to config file | ||
| const config = loadConfigFile(); | ||
| return { | ||
| directories: config.skillDirectories.map((p) => ({ | ||
| path: p, | ||
| source: "config", | ||
| type: getSourceType(p), | ||
| skillCount: 0, | ||
| valid: isValidPath(p), | ||
| allowed: isPathAllowed(p), | ||
| })), | ||
| activeSource: "config", | ||
| isOverridden: false, | ||
| }; | ||
| } | ||
| /** | ||
| * Get skill directories from all sources combined. | ||
| * Used for the UI to show all configured directories. | ||
| */ | ||
| export function getAllDirectoriesWithSources() { | ||
| const all = []; | ||
| const seen = new Set(); | ||
| // CLI dirs | ||
| for (const p of parseCLIArgs()) { | ||
| if (!seen.has(p)) { | ||
| seen.add(p); | ||
| all.push({ | ||
| path: p, | ||
| source: "cli", | ||
| type: getSourceType(p), | ||
| skillCount: 0, | ||
| valid: isValidPath(p), | ||
| allowed: isPathAllowed(p), | ||
| }); | ||
| } | ||
| } | ||
| // Env dirs | ||
| for (const p of parseEnvVar()) { | ||
| if (!seen.has(p)) { | ||
| seen.add(p); | ||
| all.push({ | ||
| path: p, | ||
| source: "env", | ||
| type: getSourceType(p), | ||
| skillCount: 0, | ||
| valid: isValidPath(p), | ||
| allowed: isPathAllowed(p), | ||
| }); | ||
| } | ||
| } | ||
| // Config file dirs | ||
| const config = loadConfigFile(); | ||
| for (const p of config.skillDirectories) { | ||
| if (!seen.has(p)) { | ||
| seen.add(p); | ||
| all.push({ | ||
| path: p, | ||
| source: "config", | ||
| type: getSourceType(p), | ||
| skillCount: 0, | ||
| valid: isValidPath(p), | ||
| allowed: isPathAllowed(p), | ||
| }); | ||
| } | ||
| } | ||
| return all; | ||
| } | ||
| /** | ||
| * Add a directory or GitHub URL to the config file. | ||
| * Does not affect CLI or env var configurations. | ||
| */ | ||
| export function addDirectoryToConfig(directory) { | ||
| // For GitHub URLs, store as-is; for local paths, resolve to absolute | ||
| const normalized = isGitHubUrl(directory) ? directory : path.resolve(directory); | ||
| // Validate local directories exist | ||
| if (!isGitHubUrl(directory)) { | ||
| if (!fs.existsSync(normalized)) { | ||
| throw new Error(`Directory does not exist: ${normalized}`); | ||
| } | ||
| if (!fs.statSync(normalized).isDirectory()) { | ||
| throw new Error(`Path is not a directory: ${normalized}`); | ||
| } | ||
| } | ||
| const config = loadConfigFile(); | ||
| // Check for duplicate | ||
| if (config.skillDirectories.includes(normalized)) { | ||
| throw new Error(`Already configured: ${normalized}`); | ||
| } | ||
| config.skillDirectories.push(normalized); | ||
| saveConfigFile(config); | ||
| } | ||
| /** | ||
| * Remove a directory or GitHub URL from the config file. | ||
| * Only removes from config file, not CLI or env var. | ||
| */ | ||
| export function removeDirectoryFromConfig(directory) { | ||
| // For GitHub URLs, use as-is; for local paths, resolve to absolute | ||
| const normalized = isGitHubUrl(directory) ? directory : path.resolve(directory); | ||
| const config = loadConfigFile(); | ||
| const index = config.skillDirectories.indexOf(normalized); | ||
| if (index === -1) { | ||
| throw new Error(`Not found in config: ${normalized}`); | ||
| } | ||
| config.skillDirectories.splice(index, 1); | ||
| saveConfigFile(config); | ||
| } | ||
| /** | ||
| * Get only the active skill directories (respecting priority). | ||
| * This is what the server should use for skill discovery. | ||
| */ | ||
| export function getActiveDirectories() { | ||
| const state = getConfigState(); | ||
| return state.directories.map((d) => d.path); | ||
| } | ||
| /** | ||
| * Get static mode setting from config file. | ||
| */ | ||
| export function getStaticModeFromConfig() { | ||
| const config = loadConfigFile(); | ||
| return config.staticMode === true; | ||
| } | ||
| /** | ||
| * Set static mode setting in config file. | ||
| */ | ||
| export function setStaticModeInConfig(enabled) { | ||
| const config = loadConfigFile(); | ||
| config.staticMode = enabled; | ||
| saveConfigFile(config); | ||
| } | ||
| /** | ||
| * Get all skill invocation overrides from the config file. | ||
| */ | ||
| export function getSkillInvocationOverrides() { | ||
| const config = loadConfigFile(); | ||
| return config.skillInvocationOverrides || {}; | ||
| } | ||
| /** | ||
| * Set an invocation override for a skill. | ||
| * @param skillName - The name of the skill | ||
| * @param setting - Which setting to override ("assistant" or "user") | ||
| * @param value - The new value for the setting | ||
| */ | ||
| export function setSkillInvocationOverride(skillName, setting, value) { | ||
| const config = loadConfigFile(); | ||
| if (!config.skillInvocationOverrides) { | ||
| config.skillInvocationOverrides = {}; | ||
| } | ||
| if (!config.skillInvocationOverrides[skillName]) { | ||
| config.skillInvocationOverrides[skillName] = {}; | ||
| } | ||
| config.skillInvocationOverrides[skillName][setting] = value; | ||
| saveConfigFile(config); | ||
| } | ||
| /** | ||
| * Clear an invocation override for a skill (revert to frontmatter default). | ||
| * @param skillName - The name of the skill | ||
| * @param setting - Which setting to clear (omit to clear both) | ||
| */ | ||
| export function clearSkillInvocationOverride(skillName, setting) { | ||
| const config = loadConfigFile(); | ||
| if (!config.skillInvocationOverrides || !config.skillInvocationOverrides[skillName]) { | ||
| return; // Nothing to clear | ||
| } | ||
| if (setting) { | ||
| delete config.skillInvocationOverrides[skillName][setting]; | ||
| // Clean up empty override objects | ||
| if (Object.keys(config.skillInvocationOverrides[skillName]).length === 0) { | ||
| delete config.skillInvocationOverrides[skillName]; | ||
| } | ||
| } | ||
| else { | ||
| delete config.skillInvocationOverrides[skillName]; | ||
| } | ||
| saveConfigFile(config); | ||
| } | ||
| /** | ||
| * Get the GitHub allowed orgs from config file. | ||
| */ | ||
| export function getGitHubAllowedOrgs() { | ||
| const config = loadConfigFile(); | ||
| return config.githubAllowedOrgs || []; | ||
| } | ||
| /** | ||
| * Get the GitHub allowed users from config file. | ||
| */ | ||
| export function getGitHubAllowedUsers() { | ||
| const config = loadConfigFile(); | ||
| return config.githubAllowedUsers || []; | ||
| } | ||
| /** | ||
| * Add a GitHub org to the allowed list. | ||
| * @param org - The org name to allow | ||
| */ | ||
| export function addGitHubAllowedOrg(org) { | ||
| const config = loadConfigFile(); | ||
| if (!config.githubAllowedOrgs) { | ||
| config.githubAllowedOrgs = []; | ||
| } | ||
| const normalized = org.toLowerCase().trim(); | ||
| if (!config.githubAllowedOrgs.some((o) => o.toLowerCase() === normalized)) { | ||
| config.githubAllowedOrgs.push(org.trim()); | ||
| saveConfigFile(config); | ||
| } | ||
| } | ||
| /** | ||
| * Remove a GitHub org from the allowed list. | ||
| * @param org - The org name to remove | ||
| */ | ||
| export function removeGitHubAllowedOrg(org) { | ||
| const config = loadConfigFile(); | ||
| if (!config.githubAllowedOrgs) { | ||
| return; | ||
| } | ||
| const normalized = org.toLowerCase().trim(); | ||
| config.githubAllowedOrgs = config.githubAllowedOrgs.filter((o) => o.toLowerCase() !== normalized); | ||
| saveConfigFile(config); | ||
| } | ||
| /** | ||
| * Add a GitHub user to the allowed list. | ||
| * @param user - The user name to allow | ||
| */ | ||
| export function addGitHubAllowedUser(user) { | ||
| const config = loadConfigFile(); | ||
| if (!config.githubAllowedUsers) { | ||
| config.githubAllowedUsers = []; | ||
| } | ||
| const normalized = user.toLowerCase().trim(); | ||
| if (!config.githubAllowedUsers.some((u) => u.toLowerCase() === normalized)) { | ||
| config.githubAllowedUsers.push(user.trim()); | ||
| saveConfigFile(config); | ||
| } | ||
| } | ||
| /** | ||
| * Remove a GitHub user from the allowed list. | ||
| * @param user - The user name to remove | ||
| */ | ||
| export function removeGitHubAllowedUser(user) { | ||
| const config = loadConfigFile(); | ||
| if (!config.githubAllowedUsers) { | ||
| return; | ||
| } | ||
| const normalized = user.toLowerCase().trim(); | ||
| config.githubAllowedUsers = config.githubAllowedUsers.filter((u) => u.toLowerCase() !== normalized); | ||
| saveConfigFile(config); | ||
| } |
| /** | ||
| * MCP App tool registration for skill display UI. | ||
| * | ||
| * Registers: | ||
| * - skill-display: Opens the skill display UI | ||
| * - skill-display-update-invocation: Updates invocation settings (UI-only) | ||
| * - skill-display-reset-override: Resets a skill to frontmatter defaults (UI-only) | ||
| */ | ||
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { SkillState } from "./skill-tool.js"; | ||
| /** | ||
| * Callback type for when invocation settings change. | ||
| */ | ||
| export type OnInvocationChangedCallback = () => void; | ||
| /** | ||
| * Register skill-display MCP App tools and resource. | ||
| * | ||
| * @param server - The MCP server instance | ||
| * @param skillState - Shared skill state for getting skill info | ||
| * @param onInvocationChanged - Callback when invocation settings are changed | ||
| */ | ||
| export declare function registerSkillDisplayTool(server: McpServer, skillState: SkillState, onInvocationChanged: OnInvocationChangedCallback): void; |
| /** | ||
| * MCP App tool registration for skill display UI. | ||
| * | ||
| * Registers: | ||
| * - skill-display: Opens the skill display UI | ||
| * - skill-display-update-invocation: Updates invocation settings (UI-only) | ||
| * - skill-display-reset-override: Resets a skill to frontmatter defaults (UI-only) | ||
| */ | ||
| import * as fs from "node:fs"; | ||
| import * as fsPromises from "node:fs/promises"; | ||
| import * as path from "node:path"; | ||
| import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; | ||
| import { z } from "zod"; | ||
| import { setSkillInvocationOverride, clearSkillInvocationOverride, } from "./skill-config.js"; | ||
| /** | ||
| * Resource URI for the skill-display UI. | ||
| */ | ||
| const RESOURCE_URI = "ui://skill-display/skill-display.html"; | ||
| /** | ||
| * Get the path to the bundled UI HTML file. | ||
| */ | ||
| function getUIPath() { | ||
| const possiblePaths = [ | ||
| // From dist/skill-display-tool.js | ||
| path.join(import.meta.dirname, "ui", "skill-display.html"), | ||
| // From src/ during development | ||
| path.join(import.meta.dirname, "..", "dist", "ui", "skill-display.html"), | ||
| ]; | ||
| for (const p of possiblePaths) { | ||
| if (fs.existsSync(p)) { | ||
| return p; | ||
| } | ||
| } | ||
| throw new Error(`UI file not found. Tried: ${possiblePaths.join(", ")}. ` + | ||
| "Run 'npm run build:ui:display' to build the UI."); | ||
| } | ||
| /** | ||
| * Schema shape for update-invocation tool input. | ||
| */ | ||
| const UpdateInvocationInputSchema = { | ||
| skillName: z.string().describe("Name of the skill to update"), | ||
| setting: z.enum(["assistant", "user"]).describe("Which setting to update"), | ||
| value: z.boolean().describe("New value for the setting"), | ||
| }; | ||
| /** | ||
| * Schema shape for reset-override tool input. | ||
| */ | ||
| const ResetOverrideInputSchema = { | ||
| skillName: z.string().describe("Name of the skill to reset"), | ||
| setting: z.enum(["assistant", "user"]).optional().describe("Which setting to reset (omit for both)"), | ||
| }; | ||
| /** | ||
| * Get skill display info from skill state. | ||
| */ | ||
| function getSkillDisplayInfo(skillState) { | ||
| const skills = []; | ||
| for (const skill of skillState.skillMap.values()) { | ||
| skills.push({ | ||
| name: skill.name, | ||
| description: skill.description, | ||
| path: skill.path, | ||
| assistantInvocable: skill.effectiveAssistantInvocable, | ||
| userInvocable: skill.effectiveUserInvocable, | ||
| isAssistantOverridden: skill.isAssistantOverridden, | ||
| isUserOverridden: skill.isUserOverridden, | ||
| // Source info | ||
| sourceType: skill.source.type, | ||
| sourceDisplayName: skill.source.displayName, | ||
| sourceOwner: skill.source.owner, | ||
| sourceRepo: skill.source.repo, | ||
| }); | ||
| } | ||
| // Sort by name for consistent display | ||
| skills.sort((a, b) => a.name.localeCompare(b.name)); | ||
| return skills; | ||
| } | ||
| /** | ||
| * Register skill-display MCP App tools and resource. | ||
| * | ||
| * @param server - The MCP server instance | ||
| * @param skillState - Shared skill state for getting skill info | ||
| * @param onInvocationChanged - Callback when invocation settings are changed | ||
| */ | ||
| export function registerSkillDisplayTool(server, skillState, onInvocationChanged) { | ||
| // Main display tool - opens UI | ||
| registerAppTool(server, "skill-display", { | ||
| title: "View Skills", | ||
| description: "Open the skill display UI to view available skills and configure their invocation settings. " + | ||
| "Use when user wants to see skills or toggle assistant/user invocation.", | ||
| inputSchema: {}, | ||
| outputSchema: { | ||
| skills: z.array(z.object({ | ||
| name: z.string(), | ||
| description: z.string(), | ||
| path: z.string(), | ||
| assistantInvocable: z.boolean(), | ||
| userInvocable: z.boolean(), | ||
| isAssistantOverridden: z.boolean(), | ||
| isUserOverridden: z.boolean(), | ||
| sourceType: z.enum(["local", "github", "bundled"]), | ||
| sourceDisplayName: z.string(), | ||
| sourceOwner: z.string().optional(), | ||
| sourceRepo: z.string().optional(), | ||
| })), | ||
| totalCount: z.number(), | ||
| }, | ||
| _meta: { ui: { resourceUri: RESOURCE_URI } }, | ||
| annotations: { | ||
| readOnlyHint: true, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: false, | ||
| }, | ||
| }, async () => { | ||
| const skills = getSkillDisplayInfo(skillState); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Skill display UI opened. ${skills.length} skill(s) available.`, | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| skills, | ||
| totalCount: skills.length, | ||
| }, | ||
| }; | ||
| }); | ||
| // Update invocation tool (UI-only, hidden from model) | ||
| registerAppTool(server, "skill-display-update-invocation", { | ||
| title: "Update Skill Invocation", | ||
| description: "Update invocation settings for a skill.", | ||
| inputSchema: UpdateInvocationInputSchema, | ||
| outputSchema: { | ||
| success: z.boolean(), | ||
| skills: z.array(z.object({ | ||
| name: z.string(), | ||
| description: z.string(), | ||
| path: z.string(), | ||
| assistantInvocable: z.boolean(), | ||
| userInvocable: z.boolean(), | ||
| isAssistantOverridden: z.boolean(), | ||
| isUserOverridden: z.boolean(), | ||
| sourceType: z.enum(["local", "github", "bundled"]), | ||
| sourceDisplayName: z.string(), | ||
| sourceOwner: z.string().optional(), | ||
| sourceRepo: z.string().optional(), | ||
| })).optional(), | ||
| totalCount: z.number().optional(), | ||
| error: z.string().optional(), | ||
| }, | ||
| _meta: { | ||
| ui: { | ||
| resourceUri: RESOURCE_URI, | ||
| visibility: ["app"], // Hidden from model, UI can call it | ||
| }, | ||
| }, | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: false, | ||
| idempotentHint: false, | ||
| openWorldHint: false, | ||
| }, | ||
| }, async (args) => { | ||
| const { skillName, setting, value } = args; | ||
| try { | ||
| // Verify skill exists | ||
| if (!skillState.skillMap.has(skillName)) { | ||
| throw new Error(`Skill not found: ${skillName}`); | ||
| } | ||
| // Update the override | ||
| setSkillInvocationOverride(skillName, setting, value); | ||
| // Trigger refresh to apply the new override | ||
| onInvocationChanged(); | ||
| // Return updated skill list | ||
| const skills = getSkillDisplayInfo(skillState); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Updated ${skillName}: ${setting} = ${value}`, | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| success: true, | ||
| skills, | ||
| totalCount: skills.length, | ||
| }, | ||
| }; | ||
| } | ||
| catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Failed to update invocation: ${message}`, | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| success: false, | ||
| error: message, | ||
| }, | ||
| isError: true, | ||
| }; | ||
| } | ||
| }); | ||
| // Reset override tool (UI-only, hidden from model) | ||
| registerAppTool(server, "skill-display-reset-override", { | ||
| title: "Reset Skill Override", | ||
| description: "Reset a skill's invocation settings to frontmatter defaults.", | ||
| inputSchema: ResetOverrideInputSchema, | ||
| outputSchema: { | ||
| success: z.boolean(), | ||
| skills: z.array(z.object({ | ||
| name: z.string(), | ||
| description: z.string(), | ||
| path: z.string(), | ||
| assistantInvocable: z.boolean(), | ||
| userInvocable: z.boolean(), | ||
| isAssistantOverridden: z.boolean(), | ||
| isUserOverridden: z.boolean(), | ||
| sourceType: z.enum(["local", "github", "bundled"]), | ||
| sourceDisplayName: z.string(), | ||
| sourceOwner: z.string().optional(), | ||
| sourceRepo: z.string().optional(), | ||
| })).optional(), | ||
| totalCount: z.number().optional(), | ||
| error: z.string().optional(), | ||
| }, | ||
| _meta: { | ||
| ui: { | ||
| resourceUri: RESOURCE_URI, | ||
| visibility: ["app"], // Hidden from model, UI can call it | ||
| }, | ||
| }, | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: false, | ||
| }, | ||
| }, async (args) => { | ||
| const { skillName, setting } = args; | ||
| try { | ||
| // Verify skill exists | ||
| if (!skillState.skillMap.has(skillName)) { | ||
| throw new Error(`Skill not found: ${skillName}`); | ||
| } | ||
| // Clear the override | ||
| clearSkillInvocationOverride(skillName, setting); | ||
| // Trigger refresh to apply the change | ||
| onInvocationChanged(); | ||
| // Return updated skill list | ||
| const skills = getSkillDisplayInfo(skillState); | ||
| const settingText = setting ? setting : "all settings"; | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Reset ${skillName} (${settingText}) to frontmatter default`, | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| success: true, | ||
| skills, | ||
| totalCount: skills.length, | ||
| }, | ||
| }; | ||
| } | ||
| catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Failed to reset override: ${message}`, | ||
| }, | ||
| ], | ||
| structuredContent: { | ||
| success: false, | ||
| error: message, | ||
| }, | ||
| isError: true, | ||
| }; | ||
| } | ||
| }); | ||
| // Register the HTML UI resource | ||
| registerAppResource(server, RESOURCE_URI, RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => { | ||
| const uiPath = getUIPath(); | ||
| const html = await fsPromises.readFile(uiPath, "utf-8"); | ||
| return { | ||
| contents: [ | ||
| { | ||
| uri: RESOURCE_URI, | ||
| mimeType: RESOURCE_MIME_TYPE, | ||
| text: html, | ||
| }, | ||
| ], | ||
| }; | ||
| }); | ||
| } |
| export {}; |
Sorry, the diff of this file is too big to display
| /** | ||
| * Skills Configuration MCP App - Vanilla JS implementation | ||
| */ | ||
| import { App, applyDocumentTheme, applyHostStyleVariables, applyHostFonts, } from "@modelcontextprotocol/ext-apps"; | ||
| // State | ||
| let directories = []; | ||
| let activeSource = "config"; | ||
| let isOverridden = false; | ||
| let staticMode = false; | ||
| let allowedOrgs = []; | ||
| let allowedUsers = []; | ||
| let app = null; | ||
| // DOM Elements | ||
| const directoryList = document.getElementById("directory-list"); | ||
| const stats = document.getElementById("stats"); | ||
| const addBtn = document.getElementById("add-btn"); | ||
| const addModal = document.getElementById("add-modal"); | ||
| const overrideBanner = document.getElementById("override-banner"); | ||
| const overrideSource = document.getElementById("override-source"); | ||
| const toast = document.getElementById("toast"); | ||
| const directoryInput = document.getElementById("directory-path"); | ||
| const addSubmitBtn = document.getElementById("add-submit-btn"); | ||
| // Allowed orgs DOM elements | ||
| const allowedOrgsList = document.getElementById("allowed-orgs-list"); | ||
| const addOrgBtn = document.getElementById("add-org-btn"); | ||
| const addOrgModal = document.getElementById("add-org-modal"); | ||
| const orgNameInput = document.getElementById("org-name"); | ||
| const addOrgSubmitBtn = document.getElementById("add-org-submit-btn"); | ||
| // Static mode DOM element | ||
| const staticModeToggle = document.getElementById("static-mode-toggle"); | ||
| // Confirm remove modal DOM elements | ||
| const confirmRemoveModal = document.getElementById("confirm-remove-modal"); | ||
| const confirmRemovePath = document.getElementById("confirm-remove-path"); | ||
| const confirmRemoveBtn = document.getElementById("confirm-remove-btn"); | ||
| const confirmRemoveCancel = document.getElementById("confirm-remove-cancel"); | ||
| const confirmRemoveClose = document.getElementById("confirm-remove-close"); | ||
| // Track pending removal | ||
| let pendingRemovePath = null; | ||
| let pendingRemoveOrg = null; | ||
| // Confirm remove org modal DOM elements | ||
| const confirmRemoveOrgModal = document.getElementById("confirm-remove-org-modal"); | ||
| const confirmRemoveOrgName = document.getElementById("confirm-remove-org-name"); | ||
| const confirmRemoveOrgBtn = document.getElementById("confirm-remove-org-btn"); | ||
| const confirmRemoveOrgCancel = document.getElementById("confirm-remove-org-cancel"); | ||
| const confirmRemoveOrgClose = document.getElementById("confirm-remove-org-close"); | ||
| // Handle host context changes | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| function handleHostContextChanged(ctx) { | ||
| if (ctx.theme) { | ||
| applyDocumentTheme(ctx.theme); | ||
| } | ||
| if (ctx.styles?.variables) { | ||
| applyHostStyleVariables(ctx.styles.variables); | ||
| } | ||
| if (ctx.styles?.css?.fonts) { | ||
| applyHostFonts(ctx.styles.css.fonts); | ||
| } | ||
| // Handle safe area insets for mobile/notched devices | ||
| if (ctx.safeAreaInsets) { | ||
| const { top, right, bottom, left } = ctx.safeAreaInsets; | ||
| document.body.style.paddingTop = `${top + 16}px`; | ||
| document.body.style.paddingRight = `${right + 16}px`; | ||
| document.body.style.paddingBottom = `${bottom + 16}px`; | ||
| document.body.style.paddingLeft = `${left + 16}px`; | ||
| } | ||
| } | ||
| // Update state from tool result | ||
| function updateState(data) { | ||
| if (data.directories) { | ||
| directories = data.directories; | ||
| } | ||
| if (data.activeSource) { | ||
| activeSource = data.activeSource; | ||
| } | ||
| if (data.isOverridden !== undefined) { | ||
| isOverridden = data.isOverridden; | ||
| } | ||
| if (data.staticMode !== undefined) { | ||
| staticMode = data.staticMode; | ||
| } | ||
| if (data.allowedOrgs) { | ||
| allowedOrgs = data.allowedOrgs; | ||
| } | ||
| if (data.allowedUsers) { | ||
| allowedUsers = data.allowedUsers; | ||
| } | ||
| render(); | ||
| } | ||
| // Render the UI | ||
| function render() { | ||
| renderStats(); | ||
| renderOverrideBanner(); | ||
| renderDirectories(); | ||
| renderAllowedOrgs(); | ||
| updateAddButton(); | ||
| renderStaticModeToggle(); | ||
| } | ||
| function renderStats() { | ||
| const totalSkills = directories.reduce((sum, d) => sum + (d.skillCount || 0), 0); | ||
| const validCount = directories.filter((d) => d.valid).length; | ||
| stats.textContent = `${directories.length} directories, ${totalSkills} skills total`; | ||
| if (validCount < directories.length) { | ||
| stats.textContent += ` (${directories.length - validCount} missing)`; | ||
| } | ||
| } | ||
| function renderOverrideBanner() { | ||
| if (isOverridden) { | ||
| overrideBanner.classList.add("visible"); | ||
| overrideSource.textContent = | ||
| activeSource === "cli" ? "CLI arguments" : "SKILLS_DIR environment variable"; | ||
| } | ||
| else { | ||
| overrideBanner.classList.remove("visible"); | ||
| } | ||
| } | ||
| function renderDirectories() { | ||
| if (directories.length === 0) { | ||
| directoryList.innerHTML = ` | ||
| <div class="empty-state"> | ||
| <p>No skills directories configured.</p> | ||
| <p>Click "Add Directory" to get started.</p> | ||
| </div> | ||
| `; | ||
| return; | ||
| } | ||
| directoryList.innerHTML = directories | ||
| .map((dir) => { | ||
| const isReadOnly = dir.source !== "config"; | ||
| const isBlocked = dir.type === "github" && !dir.allowed; | ||
| return ` | ||
| <div class="directory-card ${isReadOnly ? "readonly" : ""} ${isBlocked ? "blocked" : ""}"> | ||
| ${isReadOnly ? `<span class="lock-icon" title="Read-only: configured via ${dir.source.toUpperCase()}">🔒</span>` : ""} | ||
| <div class="directory-info"> | ||
| <div class="directory-path">${escapeHtml(dir.path)}</div> | ||
| <div class="directory-meta"> | ||
| <span class="source-badge ${dir.source}">${dir.source.toUpperCase()}</span> | ||
| <span class="type-badge ${dir.type}">${dir.type.toUpperCase()}</span> | ||
| ${isBlocked | ||
| ? `<span class="blocked-badge" title="Add org/user to allowed list to sync">BLOCKED</span>` | ||
| : `<span class="skill-count">${dir.skillCount} skill${dir.skillCount !== 1 ? "s" : ""}</span>`} | ||
| <span class="validity-icon ${dir.valid ? "valid" : "invalid"}" title="${dir.valid ? "Directory exists" : "Directory not found"}"> | ||
| ${dir.valid ? "✓" : "✗"} | ||
| </span> | ||
| </div> | ||
| </div> | ||
| ${!isReadOnly ? `<button class="remove-btn" data-path="${escapeHtml(dir.path)}">Remove</button>` : ""} | ||
| </div> | ||
| `; | ||
| }) | ||
| .join(""); | ||
| // Add click handlers for remove buttons | ||
| directoryList.querySelectorAll(".remove-btn").forEach((btn) => { | ||
| btn.addEventListener("click", () => { | ||
| const path = btn.dataset.path; | ||
| if (path) { | ||
| showConfirmRemoveModal(path); | ||
| } | ||
| }); | ||
| }); | ||
| } | ||
| function updateAddButton() { | ||
| // Disable add button if directories are overridden | ||
| addBtn.disabled = isOverridden; | ||
| if (isOverridden) { | ||
| addBtn.title = | ||
| "Cannot add directories while " + | ||
| (activeSource === "cli" ? "CLI args" : "env var") + | ||
| " override is active"; | ||
| } | ||
| else { | ||
| addBtn.title = ""; | ||
| } | ||
| } | ||
| function renderStaticModeToggle() { | ||
| if (staticModeToggle) { | ||
| staticModeToggle.checked = staticMode; | ||
| } | ||
| } | ||
| // Toggle static mode | ||
| async function setStaticModeEnabled(enabled) { | ||
| staticModeToggle.disabled = true; | ||
| try { | ||
| const result = await app.callServerTool({ | ||
| name: "skill-config-set-static-mode", | ||
| arguments: { enabled }, | ||
| }); | ||
| console.log("Static mode result:", result); | ||
| const structured = result.structuredContent; | ||
| if (structured?.success) { | ||
| staticMode = structured.staticMode ?? enabled; | ||
| renderStaticModeToggle(); | ||
| showToast(enabled | ||
| ? "Static mode enabled. Restart server for changes to take effect." | ||
| : "Static mode disabled. Restart server for changes to take effect.", "success"); | ||
| } | ||
| else { | ||
| // Revert toggle on failure | ||
| staticModeToggle.checked = staticMode; | ||
| showToast(structured?.error || "Failed to change static mode", "error"); | ||
| } | ||
| } | ||
| catch (error) { | ||
| console.error("Set static mode error:", error); | ||
| // Revert toggle on error | ||
| staticModeToggle.checked = staticMode; | ||
| showToast(error.message || "Failed to change static mode", "error"); | ||
| } | ||
| finally { | ||
| staticModeToggle.disabled = false; | ||
| } | ||
| } | ||
| // Add directory | ||
| async function addDirectory() { | ||
| const path = directoryInput.value.trim(); | ||
| if (!path) { | ||
| showToast("Please enter a directory path", "error"); | ||
| return; | ||
| } | ||
| addSubmitBtn.disabled = true; | ||
| addSubmitBtn.textContent = "Adding..."; | ||
| try { | ||
| const result = await app.callServerTool({ | ||
| name: "skill-config-add-directory", | ||
| arguments: { directory: path }, | ||
| }); | ||
| console.log("Add result:", result); | ||
| const structured = result.structuredContent; | ||
| if (structured?.success) { | ||
| updateState(structured); | ||
| closeAddModal(); | ||
| showToast("Directory added successfully", "success"); | ||
| } | ||
| else { | ||
| showToast(structured?.error || "Failed to add directory", "error"); | ||
| } | ||
| } | ||
| catch (error) { | ||
| console.error("Add directory error:", error); | ||
| showToast(error.message || "Failed to add directory", "error"); | ||
| } | ||
| finally { | ||
| addSubmitBtn.disabled = false; | ||
| addSubmitBtn.textContent = "Add Directory"; | ||
| } | ||
| } | ||
| // Show confirmation modal for removing a directory | ||
| function showConfirmRemoveModal(path) { | ||
| pendingRemovePath = path; | ||
| confirmRemovePath.textContent = path; | ||
| confirmRemoveModal.classList.add("active"); | ||
| } | ||
| // Hide confirmation modal | ||
| function closeConfirmRemoveModal() { | ||
| confirmRemoveModal.classList.remove("active"); | ||
| pendingRemovePath = null; | ||
| } | ||
| // Remove directory (called after confirmation) | ||
| async function removeDirectory(path) { | ||
| try { | ||
| const result = await app.callServerTool({ | ||
| name: "skill-config-remove-directory", | ||
| arguments: { directory: path }, | ||
| }); | ||
| console.log("Remove result:", result); | ||
| const structured = result.structuredContent; | ||
| if (structured?.success) { | ||
| updateState(structured); | ||
| showToast("Directory removed", "success"); | ||
| } | ||
| else { | ||
| showToast(structured?.error || "Failed to remove directory", "error"); | ||
| } | ||
| } | ||
| catch (error) { | ||
| console.error("Remove directory error:", error); | ||
| showToast(error.message || "Failed to remove directory", "error"); | ||
| } | ||
| } | ||
| // Render allowed orgs list | ||
| function renderAllowedOrgs() { | ||
| if (allowedOrgs.length === 0) { | ||
| allowedOrgsList.innerHTML = ` | ||
| <div class="empty-state small"> | ||
| No allowed orgs configured. GitHub repos are blocked until an org/user is added. | ||
| </div> | ||
| `; | ||
| return; | ||
| } | ||
| allowedOrgsList.innerHTML = allowedOrgs | ||
| .map((org) => ` | ||
| <div class="allowed-item"> | ||
| <span class="allowed-name">${escapeHtml(org)}</span> | ||
| <button class="remove-org-btn" data-org="${escapeHtml(org)}">Remove</button> | ||
| </div> | ||
| `) | ||
| .join(""); | ||
| // Add click handlers for remove buttons | ||
| allowedOrgsList.querySelectorAll(".remove-org-btn").forEach((btn) => { | ||
| btn.addEventListener("click", () => { | ||
| const org = btn.dataset.org; | ||
| if (org) { | ||
| showConfirmRemoveOrgModal(org); | ||
| } | ||
| }); | ||
| }); | ||
| } | ||
| // Add allowed org | ||
| async function addAllowedOrg() { | ||
| const org = orgNameInput.value.trim(); | ||
| if (!org) { | ||
| showToast("Please enter an organization name", "error"); | ||
| return; | ||
| } | ||
| addOrgSubmitBtn.disabled = true; | ||
| addOrgSubmitBtn.textContent = "Adding..."; | ||
| try { | ||
| const result = await app.callServerTool({ | ||
| name: "skill-config-add-allowed-org", | ||
| arguments: { org }, | ||
| }); | ||
| console.log("Add org result:", result); | ||
| const structured = result.structuredContent; | ||
| if (structured?.success) { | ||
| updateState(structured); | ||
| closeOrgModal(); | ||
| showToast(`Added allowed org: ${org}`, "success"); | ||
| } | ||
| else { | ||
| showToast(structured?.error || "Failed to add org", "error"); | ||
| } | ||
| } | ||
| catch (error) { | ||
| console.error("Add org error:", error); | ||
| showToast(error.message || "Failed to add org", "error"); | ||
| } | ||
| finally { | ||
| addOrgSubmitBtn.disabled = false; | ||
| addOrgSubmitBtn.textContent = "Add Org"; | ||
| } | ||
| } | ||
| // Show confirmation modal for removing an allowed org | ||
| function showConfirmRemoveOrgModal(org) { | ||
| pendingRemoveOrg = org; | ||
| confirmRemoveOrgName.textContent = org; | ||
| confirmRemoveOrgModal.classList.add("active"); | ||
| } | ||
| // Hide confirmation modal for org removal | ||
| function closeConfirmRemoveOrgModal() { | ||
| confirmRemoveOrgModal.classList.remove("active"); | ||
| pendingRemoveOrg = null; | ||
| } | ||
| // Remove allowed org (called after confirmation) | ||
| async function removeAllowedOrg(org) { | ||
| try { | ||
| const result = await app.callServerTool({ | ||
| name: "skill-config-remove-allowed-org", | ||
| arguments: { org }, | ||
| }); | ||
| console.log("Remove org result:", result); | ||
| const structured = result.structuredContent; | ||
| if (structured?.success) { | ||
| updateState(structured); | ||
| showToast(`Removed allowed org: ${org}`, "success"); | ||
| } | ||
| else { | ||
| showToast(structured?.error || "Failed to remove org", "error"); | ||
| } | ||
| } | ||
| catch (error) { | ||
| console.error("Remove org error:", error); | ||
| showToast(error.message || "Failed to remove org", "error"); | ||
| } | ||
| } | ||
| // Org modal functions | ||
| function showOrgModal() { | ||
| orgNameInput.value = ""; | ||
| addOrgModal.classList.add("active"); | ||
| orgNameInput.focus(); | ||
| } | ||
| function closeOrgModal() { | ||
| addOrgModal.classList.remove("active"); | ||
| } | ||
| // Modal functions | ||
| function showAddModal() { | ||
| if (isOverridden) { | ||
| showToast("Cannot add directories while override is active", "error"); | ||
| return; | ||
| } | ||
| directoryInput.value = ""; | ||
| addModal.classList.add("active"); | ||
| directoryInput.focus(); | ||
| } | ||
| function closeAddModal() { | ||
| addModal.classList.remove("active"); | ||
| } | ||
| // Toast | ||
| function showToast(message, type = "success") { | ||
| toast.textContent = message; | ||
| toast.className = `toast ${type} visible`; | ||
| setTimeout(() => { | ||
| toast.classList.remove("visible"); | ||
| }, 3000); | ||
| } | ||
| // Utilities | ||
| function escapeHtml(str) { | ||
| if (!str) | ||
| return ""; | ||
| return String(str).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c); | ||
| } | ||
| // Set up event listeners | ||
| addBtn.addEventListener("click", showAddModal); | ||
| addModal.querySelector(".modal-close")?.addEventListener("click", closeAddModal); | ||
| addModal.querySelector(".btn-secondary")?.addEventListener("click", closeAddModal); | ||
| addSubmitBtn.addEventListener("click", addDirectory); | ||
| directoryInput.addEventListener("keydown", (e) => { | ||
| if (e.key === "Enter") { | ||
| addDirectory(); | ||
| } | ||
| }); | ||
| // Confirm remove modal event listeners | ||
| confirmRemoveBtn.addEventListener("click", async () => { | ||
| if (pendingRemovePath) { | ||
| const path = pendingRemovePath; | ||
| closeConfirmRemoveModal(); | ||
| await removeDirectory(path); | ||
| } | ||
| }); | ||
| confirmRemoveCancel.addEventListener("click", closeConfirmRemoveModal); | ||
| confirmRemoveClose.addEventListener("click", closeConfirmRemoveModal); | ||
| // Confirm remove org modal event listeners | ||
| confirmRemoveOrgBtn.addEventListener("click", async () => { | ||
| if (pendingRemoveOrg) { | ||
| const org = pendingRemoveOrg; | ||
| closeConfirmRemoveOrgModal(); | ||
| await removeAllowedOrg(org); | ||
| } | ||
| }); | ||
| confirmRemoveOrgCancel.addEventListener("click", closeConfirmRemoveOrgModal); | ||
| confirmRemoveOrgClose.addEventListener("click", closeConfirmRemoveOrgModal); | ||
| // Org modal event listeners | ||
| addOrgBtn.addEventListener("click", showOrgModal); | ||
| addOrgModal.querySelector(".modal-close")?.addEventListener("click", closeOrgModal); | ||
| addOrgModal.querySelector(".btn-secondary")?.addEventListener("click", closeOrgModal); | ||
| addOrgSubmitBtn.addEventListener("click", addAllowedOrg); | ||
| orgNameInput.addEventListener("keydown", (e) => { | ||
| if (e.key === "Enter") { | ||
| addAllowedOrg(); | ||
| } | ||
| }); | ||
| // Static mode toggle event listener | ||
| staticModeToggle?.addEventListener("change", (e) => { | ||
| const enabled = e.target.checked; | ||
| setStaticModeEnabled(enabled); | ||
| }); | ||
| // 1. Create app instance | ||
| app = new App({ name: "Skills Config", version: "1.0.0" }); | ||
| // 2. Register handlers BEFORE connecting | ||
| app.onteardown = async () => { | ||
| console.info("App is being torn down"); | ||
| return {}; | ||
| }; | ||
| app.ontoolinput = (params) => { | ||
| console.info("Received tool input:", params); | ||
| }; | ||
| app.ontoolresult = (result) => { | ||
| console.info("Received tool result:", result); | ||
| if (result.structuredContent) { | ||
| updateState(result.structuredContent); | ||
| } | ||
| }; | ||
| app.ontoolcancelled = (params) => { | ||
| console.info("Tool call cancelled:", params.reason); | ||
| }; | ||
| app.onerror = console.error; | ||
| app.onhostcontextchanged = handleHostContextChanged; | ||
| // 3. Connect to host | ||
| app.connect().then(() => { | ||
| console.info("Connected to host"); | ||
| // Apply initial host context | ||
| const ctx = app.getHostContext(); | ||
| if (ctx) { | ||
| handleHostContextChanged(ctx); | ||
| } | ||
| }); |
| export {}; |
Sorry, the diff of this file is too big to display
| /** | ||
| * Skill Display MCP App - Vanilla JS implementation | ||
| */ | ||
| import { App, applyDocumentTheme, applyHostStyleVariables, applyHostFonts, } from "@modelcontextprotocol/ext-apps"; | ||
| // State | ||
| let skills = []; | ||
| let searchQuery = ""; | ||
| let app = null; | ||
| // DOM Elements | ||
| const skillList = document.getElementById("skill-list"); | ||
| const stats = document.getElementById("stats"); | ||
| const searchInput = document.getElementById("search-input"); | ||
| const toast = document.getElementById("toast"); | ||
| // Handle host context changes | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| function handleHostContextChanged(ctx) { | ||
| if (ctx.theme) { | ||
| applyDocumentTheme(ctx.theme); | ||
| } | ||
| if (ctx.styles?.variables) { | ||
| applyHostStyleVariables(ctx.styles.variables); | ||
| } | ||
| if (ctx.styles?.css?.fonts) { | ||
| applyHostFonts(ctx.styles.css.fonts); | ||
| } | ||
| // Handle safe area insets for mobile/notched devices | ||
| if (ctx.safeAreaInsets) { | ||
| const { top, right, bottom, left } = ctx.safeAreaInsets; | ||
| document.body.style.paddingTop = `${top + 16}px`; | ||
| document.body.style.paddingRight = `${right + 16}px`; | ||
| document.body.style.paddingBottom = `${bottom + 16}px`; | ||
| document.body.style.paddingLeft = `${left + 16}px`; | ||
| } | ||
| } | ||
| // Update state from tool result | ||
| function updateState(data) { | ||
| if (data.skills) { | ||
| skills = data.skills; | ||
| } | ||
| render(); | ||
| } | ||
| // Get filtered skills based on search query | ||
| function getFilteredSkills() { | ||
| if (!searchQuery) { | ||
| return skills; | ||
| } | ||
| const query = searchQuery.toLowerCase(); | ||
| return skills.filter((skill) => skill.name.toLowerCase().includes(query) || | ||
| skill.description.toLowerCase().includes(query)); | ||
| } | ||
| // Render the UI | ||
| function render() { | ||
| renderStats(); | ||
| renderSkills(); | ||
| } | ||
| function renderStats() { | ||
| const filtered = getFilteredSkills(); | ||
| if (searchQuery) { | ||
| stats.textContent = `${filtered.length} of ${skills.length} skills`; | ||
| } | ||
| else { | ||
| stats.textContent = `${skills.length} skill${skills.length !== 1 ? "s" : ""} available`; | ||
| } | ||
| } | ||
| function renderSkills() { | ||
| const filtered = getFilteredSkills(); | ||
| if (skills.length === 0) { | ||
| skillList.innerHTML = ` | ||
| <div class="empty-state"> | ||
| <p>No skills available.</p> | ||
| <p>Configure skill directories using the skill-config tool.</p> | ||
| </div> | ||
| `; | ||
| return; | ||
| } | ||
| if (filtered.length === 0) { | ||
| skillList.innerHTML = ` | ||
| <div class="empty-state"> | ||
| <p>No skills match your search.</p> | ||
| </div> | ||
| `; | ||
| return; | ||
| } | ||
| skillList.innerHTML = filtered | ||
| .map((skill) => { | ||
| const isCustomized = skill.isAssistantOverridden || skill.isUserOverridden; | ||
| // Build source badge based on type | ||
| let sourceBadge; | ||
| if (skill.sourceType === "github") { | ||
| sourceBadge = `<span class="source-badge github" title="From GitHub: ${escapeHtml(skill.sourceDisplayName)}"> | ||
| <svg class="source-icon" viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"> | ||
| <path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/> | ||
| </svg> | ||
| ${escapeHtml(skill.sourceDisplayName)} | ||
| </span>`; | ||
| } | ||
| else if (skill.sourceType === "bundled") { | ||
| sourceBadge = `<span class="source-badge bundled" title="Bundled with server"> | ||
| <svg class="source-icon" viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"> | ||
| <path fill="currentColor" d="M8.878.392a1.75 1.75 0 0 0-1.756 0l-5.25 3.045A1.75 1.75 0 0 0 1 4.951v6.098c0 .624.332 1.2.872 1.514l5.25 3.045a1.75 1.75 0 0 0 1.756 0l5.25-3.045c.54-.313.872-.89.872-1.514V4.951c0-.624-.332-1.2-.872-1.514L8.878.392ZM8 3.5a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V4.25A.75.75 0 0 1 8 3.5Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/> | ||
| </svg> | ||
| Bundled | ||
| </span>`; | ||
| } | ||
| else { | ||
| sourceBadge = `<span class="source-badge local" title="Local skill directory"> | ||
| <svg class="source-icon" viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"> | ||
| <path fill="currentColor" d="M1.75 1A1.75 1.75 0 000 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0016 13.25v-8.5A1.75 1.75 0 0014.25 3H7.5a.25.25 0 01-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75z"/> | ||
| </svg> | ||
| Local | ||
| </span>`; | ||
| } | ||
| return ` | ||
| <div class="skill-card" data-skill="${escapeHtml(skill.name)}"> | ||
| <div class="skill-header"> | ||
| <span class="skill-name">${escapeHtml(skill.name)}</span> | ||
| <div class="skill-badges"> | ||
| ${sourceBadge} | ||
| ${isCustomized ? '<span class="customized-badge">Customized</span>' : ""} | ||
| </div> | ||
| </div> | ||
| <p class="skill-description">${escapeHtml(skill.description)}</p> | ||
| ${skill.sourceType !== "bundled" ? `<div class="skill-path">${escapeHtml(skill.path)}</div>` : ""} | ||
| <div class="skill-controls"> | ||
| <div class="toggle-group"> | ||
| <span class="toggle-label ${skill.isAssistantOverridden ? "overridden" : ""}">Assistant</span> | ||
| <div | ||
| class="toggle-switch ${skill.assistantInvocable ? "active" : ""}" | ||
| data-skill="${escapeHtml(skill.name)}" | ||
| data-setting="assistant" | ||
| data-value="${skill.assistantInvocable}" | ||
| title="${skill.assistantInvocable ? "Model can auto-invoke this skill" : "Model cannot auto-invoke this skill"}" | ||
| ></div> | ||
| </div> | ||
| <div class="toggle-group"> | ||
| <span class="toggle-label ${skill.isUserOverridden ? "overridden" : ""}">User</span> | ||
| <div | ||
| class="toggle-switch ${skill.userInvocable ? "active" : ""}" | ||
| data-skill="${escapeHtml(skill.name)}" | ||
| data-setting="user" | ||
| data-value="${skill.userInvocable}" | ||
| title="${skill.userInvocable ? "Appears in prompts menu" : "Hidden from prompts menu"}" | ||
| ></div> | ||
| </div> | ||
| <button | ||
| class="reset-btn" | ||
| data-skill="${escapeHtml(skill.name)}" | ||
| ${!isCustomized ? "disabled" : ""} | ||
| title="Reset to frontmatter defaults" | ||
| >Reset</button> | ||
| </div> | ||
| </div> | ||
| `; | ||
| }) | ||
| .join(""); | ||
| // Add click handlers for toggle switches | ||
| skillList.querySelectorAll(".toggle-switch").forEach((toggle) => { | ||
| toggle.addEventListener("click", () => { | ||
| const skillName = toggle.dataset.skill; | ||
| const setting = toggle.dataset.setting; | ||
| const currentValue = toggle.dataset.value === "true"; | ||
| if (skillName && setting) { | ||
| updateInvocation(skillName, setting, !currentValue); | ||
| } | ||
| }); | ||
| }); | ||
| // Add click handlers for reset buttons | ||
| skillList.querySelectorAll(".reset-btn").forEach((btn) => { | ||
| btn.addEventListener("click", () => { | ||
| const skillName = btn.dataset.skill; | ||
| if (skillName) { | ||
| resetOverride(skillName); | ||
| } | ||
| }); | ||
| }); | ||
| } | ||
| // Update invocation setting | ||
| async function updateInvocation(skillName, setting, value) { | ||
| try { | ||
| const result = await app.callServerTool({ | ||
| name: "skill-display-update-invocation", | ||
| arguments: { skillName, setting, value }, | ||
| }); | ||
| console.log("Update result:", result); | ||
| const structured = result.structuredContent; | ||
| if (structured?.success) { | ||
| updateState(structured); | ||
| showToast(`${skillName}: ${setting} = ${value ? "on" : "off"}`, "success"); | ||
| } | ||
| else { | ||
| showToast(structured?.error || "Failed to update", "error"); | ||
| } | ||
| } | ||
| catch (error) { | ||
| console.error("Update invocation error:", error); | ||
| showToast(error.message || "Failed to update", "error"); | ||
| } | ||
| } | ||
| // Reset override | ||
| async function resetOverride(skillName) { | ||
| try { | ||
| const result = await app.callServerTool({ | ||
| name: "skill-display-reset-override", | ||
| arguments: { skillName }, | ||
| }); | ||
| console.log("Reset result:", result); | ||
| const structured = result.structuredContent; | ||
| if (structured?.success) { | ||
| updateState(structured); | ||
| showToast(`${skillName} reset to defaults`, "success"); | ||
| } | ||
| else { | ||
| showToast(structured?.error || "Failed to reset", "error"); | ||
| } | ||
| } | ||
| catch (error) { | ||
| console.error("Reset override error:", error); | ||
| showToast(error.message || "Failed to reset", "error"); | ||
| } | ||
| } | ||
| // Toast | ||
| function showToast(message, type = "success") { | ||
| toast.textContent = message; | ||
| toast.className = `toast ${type} visible`; | ||
| setTimeout(() => { | ||
| toast.classList.remove("visible"); | ||
| }, 3000); | ||
| } | ||
| // Utilities | ||
| function escapeHtml(str) { | ||
| if (!str) | ||
| return ""; | ||
| return String(str).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c); | ||
| } | ||
| // Set up event listeners | ||
| searchInput.addEventListener("input", () => { | ||
| searchQuery = searchInput.value.trim(); | ||
| render(); | ||
| }); | ||
| // 1. Create app instance | ||
| app = new App({ name: "Skill Display", version: "1.0.0" }); | ||
| // 2. Register handlers BEFORE connecting | ||
| app.onteardown = async () => { | ||
| console.info("App is being torn down"); | ||
| return {}; | ||
| }; | ||
| app.ontoolinput = (params) => { | ||
| console.info("Received tool input:", params); | ||
| }; | ||
| app.ontoolresult = (result) => { | ||
| console.info("Received tool result:", result); | ||
| if (result.structuredContent) { | ||
| updateState(result.structuredContent); | ||
| } | ||
| }; | ||
| app.ontoolcancelled = (params) => { | ||
| console.info("Tool call cancelled:", params.reason); | ||
| }; | ||
| app.onerror = console.error; | ||
| app.onhostcontextchanged = handleHostContextChanged; | ||
| // 3. Connect to host | ||
| app.connect().then(() => { | ||
| console.info("Connected to host"); | ||
| // Apply initial host context | ||
| const ctx = app.getHostContext(); | ||
| if (ctx) { | ||
| handleHostContextChanged(ctx); | ||
| } | ||
| }); |
+12
-3
@@ -9,6 +9,15 @@ #!/usr/bin/env node | ||
| * Usage: | ||
| * skilljack-mcp /path/to/skills [/path2 ...] # One or more directories | ||
| * SKILLS_DIR=/path/to/skills skilljack-mcp # Single directory via env | ||
| * SKILLS_DIR=/path1,/path2 skilljack-mcp # Multiple (comma-separated) | ||
| * skilljack-mcp /path/to/skills [/path2 ...] # Local directories | ||
| * skilljack-mcp --static /path/to/skills # Static mode (no file watching) | ||
| * skilljack-mcp github.com/owner/repo # GitHub repository | ||
| * skilljack-mcp /local github.com/owner/repo # Mixed local + GitHub | ||
| * SKILLS_DIR=/path,github.com/owner/repo skilljack-mcp # Via environment | ||
| * SKILLJACK_STATIC=true skilljack-mcp # Static mode via env | ||
| * (or configure local directories via the skill-config UI) | ||
| * | ||
| * Options: | ||
| * --static Freeze skills list at startup. Disables file watching and | ||
| * tools/prompts listChanged notifications. Resource subscriptions | ||
| * remain fully dynamic. | ||
| */ | ||
| export {}; |
+309
-53
@@ -9,5 +9,14 @@ #!/usr/bin/env node | ||
| * Usage: | ||
| * skilljack-mcp /path/to/skills [/path2 ...] # One or more directories | ||
| * SKILLS_DIR=/path/to/skills skilljack-mcp # Single directory via env | ||
| * SKILLS_DIR=/path1,/path2 skilljack-mcp # Multiple (comma-separated) | ||
| * skilljack-mcp /path/to/skills [/path2 ...] # Local directories | ||
| * skilljack-mcp --static /path/to/skills # Static mode (no file watching) | ||
| * skilljack-mcp github.com/owner/repo # GitHub repository | ||
| * skilljack-mcp /local github.com/owner/repo # Mixed local + GitHub | ||
| * SKILLS_DIR=/path,github.com/owner/repo skilljack-mcp # Via environment | ||
| * SKILLJACK_STATIC=true skilljack-mcp # Static mode via env | ||
| * (or configure local directories via the skill-config UI) | ||
| * | ||
| * Options: | ||
| * --static Freeze skills list at startup. Disables file watching and | ||
| * tools/prompts listChanged notifications. Resource subscriptions | ||
| * remain fully dynamic. | ||
| */ | ||
@@ -19,6 +28,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import * as path from "node:path"; | ||
| import { discoverSkills, createSkillMap } from "./skill-discovery.js"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { discoverSkills, createSkillMap, applyInvocationOverrides, DEFAULT_SKILL_SOURCE, BUNDLED_SKILL_SOURCE } from "./skill-discovery.js"; | ||
| import { registerSkillTool, getToolDescription } from "./skill-tool.js"; | ||
| import { registerSkillResources } from "./skill-resources.js"; | ||
| import { registerSkillPrompts, refreshPrompts } from "./skill-prompts.js"; | ||
| import { createSubscriptionManager, registerSubscriptionHandlers, refreshSubscriptions, } from "./subscriptions.js"; | ||
| import { getActiveDirectories, getSkillInvocationOverrides, getStaticModeFromConfig } from "./skill-config.js"; | ||
| import { registerSkillConfigTool } from "./skill-config-tool.js"; | ||
| import { registerSkillDisplayTool } from "./skill-display-tool.js"; | ||
| import { isGitHubUrl, parseGitHubUrl, isRepoAllowed, getGitHubConfig, getRepoCachePath, } from "./github-config.js"; | ||
| import { syncAllRepos } from "./github-sync.js"; | ||
| import { createPollingManager } from "./github-polling.js"; | ||
| /** | ||
@@ -29,38 +46,129 @@ * Subdirectories to check for skills within the configured directory. | ||
| /** | ||
| * Separator for multiple paths in SKILLS_DIR environment variable. | ||
| * Comma works cross-platform (not valid in file paths on any OS). | ||
| * Get the path to bundled skills directory. | ||
| * Resolves relative to the compiled module location. | ||
| */ | ||
| const PATH_LIST_SEPARATOR = ","; | ||
| function getBundledSkillsDir() { | ||
| const currentDir = path.dirname(fileURLToPath(import.meta.url)); | ||
| // From dist/index.js, go up one level to package root, then into skills/ | ||
| return path.resolve(currentDir, "..", "skills"); | ||
| } | ||
| /** | ||
| * Get the skills directories from command line args and/or environment. | ||
| * Returns deduplicated, resolved paths. | ||
| * Build a directory-to-source map from current configuration. | ||
| * Maps both main directories and their standard subdirectories. | ||
| * | ||
| * @param localDirs - Local skill directories | ||
| * @param githubSpecs - GitHub repository specifications | ||
| * @param cacheDir - GitHub cache directory path | ||
| * @param bundledDir - Optional bundled skills directory | ||
| */ | ||
| function getSkillsDirs() { | ||
| const dirs = []; | ||
| // Collect all non-flag command-line arguments (comma-separated supported) | ||
| const args = process.argv.slice(2); | ||
| for (const arg of args) { | ||
| if (!arg.startsWith("-")) { | ||
| const paths = arg | ||
| .split(PATH_LIST_SEPARATOR) | ||
| .map((p) => p.trim()) | ||
| .filter((p) => p.length > 0) | ||
| .map((p) => path.resolve(p)); | ||
| dirs.push(...paths); | ||
| function buildDirectorySourceMap(localDirs, githubSpecs, cacheDir, bundledDir) { | ||
| const map = {}; | ||
| // Map local directories | ||
| for (const dir of localDirs) { | ||
| const source = { | ||
| type: "local", | ||
| displayName: "Local", | ||
| }; | ||
| map[dir] = source; | ||
| // Also map standard subdirectories | ||
| for (const subdir of SKILL_SUBDIRS) { | ||
| map[path.join(dir, subdir)] = source; | ||
| } | ||
| } | ||
| // Also check environment variable (comma-separated supported) | ||
| const envDir = process.env.SKILLS_DIR; | ||
| if (envDir) { | ||
| const envPaths = envDir | ||
| .split(PATH_LIST_SEPARATOR) | ||
| .map((p) => p.trim()) | ||
| .filter((p) => p.length > 0) | ||
| .map((p) => path.resolve(p)); | ||
| dirs.push(...envPaths); | ||
| // Map GitHub cache directories | ||
| for (const spec of githubSpecs) { | ||
| const cachePath = getRepoCachePath(spec, cacheDir); | ||
| const source = { | ||
| type: "github", | ||
| displayName: `${spec.owner}/${spec.repo}`, | ||
| owner: spec.owner, | ||
| repo: spec.repo, | ||
| }; | ||
| map[cachePath] = source; | ||
| // Also map standard subdirectories | ||
| for (const subdir of SKILL_SUBDIRS) { | ||
| map[path.join(cachePath, subdir)] = source; | ||
| } | ||
| } | ||
| // Deduplicate by resolved path | ||
| return [...new Set(dirs)]; | ||
| // Map bundled skills directory | ||
| if (bundledDir) { | ||
| map[bundledDir] = BUNDLED_SKILL_SOURCE; | ||
| // Also map standard subdirectories | ||
| for (const subdir of SKILL_SUBDIRS) { | ||
| map[path.join(bundledDir, subdir)] = BUNDLED_SKILL_SOURCE; | ||
| } | ||
| } | ||
| return map; | ||
| } | ||
| /** | ||
| * Current skill directories (mutable to support UI-driven changes). | ||
| * This includes both local directories and GitHub cache directories. | ||
| */ | ||
| let currentSkillsDirs = []; | ||
| /** | ||
| * GitHub specs that are currently being polled. | ||
| */ | ||
| let currentGithubSpecs = []; | ||
| /** | ||
| * Current directory-to-source map for skill discovery. | ||
| * Maps directory paths to their source info (local or GitHub). | ||
| */ | ||
| let currentSourceMap = {}; | ||
| /** | ||
| * Check if static mode is enabled. | ||
| * Static mode freezes the skills list at startup - no file watching, | ||
| * no listChanged notifications for tools/prompts. | ||
| * Priority: CLI flag > env var > config file | ||
| */ | ||
| function getStaticMode() { | ||
| // Check CLI flag (highest priority) | ||
| const args = process.argv.slice(2); | ||
| if (args.includes("--static")) { | ||
| return true; | ||
| } | ||
| // Check environment variable | ||
| const envValue = process.env.SKILLJACK_STATIC?.toLowerCase(); | ||
| if (envValue === "true" || envValue === "1" || envValue === "yes") { | ||
| return true; | ||
| } | ||
| // Check config file (lowest priority) | ||
| return getStaticModeFromConfig(); | ||
| } | ||
| /** | ||
| * Classify paths as local directories or GitHub repositories. | ||
| * GitHub URLs are detected by checking for "github.com" in the path. | ||
| */ | ||
| function classifyPaths(paths) { | ||
| const localDirs = []; | ||
| const githubSpecs = []; | ||
| for (const p of paths) { | ||
| if (isGitHubUrl(p)) { | ||
| try { | ||
| const spec = parseGitHubUrl(p); | ||
| githubSpecs.push(spec); | ||
| } | ||
| catch (error) { | ||
| console.error(`Warning: Invalid GitHub URL "${p}": ${error}`); | ||
| } | ||
| } | ||
| else { | ||
| // Local directory - resolve the path | ||
| localDirs.push(path.resolve(p)); | ||
| } | ||
| } | ||
| // Deduplicate local dirs | ||
| const uniqueLocalDirs = [...new Set(localDirs)]; | ||
| // Deduplicate GitHub specs by owner/repo | ||
| const seenRepos = new Set(); | ||
| const uniqueGithubSpecs = githubSpecs.filter((spec) => { | ||
| const key = `${spec.owner}/${spec.repo}`; | ||
| if (seenRepos.has(key)) { | ||
| return false; | ||
| } | ||
| seenRepos.add(key); | ||
| return true; | ||
| }); | ||
| return { localDirs: uniqueLocalDirs, githubSpecs: uniqueGithubSpecs }; | ||
| } | ||
| /** | ||
| * Shared state for skill management. | ||
@@ -76,4 +184,7 @@ * Tools and resources reference this state. | ||
| * Handles duplicate skill names by keeping first occurrence. | ||
| * | ||
| * @param skillsDirs - The skill directories to scan | ||
| * @param sourceMap - Map from directory paths to source info | ||
| */ | ||
| function discoverSkillsFromDirs(skillsDirs) { | ||
| function discoverSkillsFromDirs(skillsDirs, sourceMap) { | ||
| const allSkills = []; | ||
@@ -87,4 +198,6 @@ const seenNames = new Map(); // name -> source directory | ||
| console.error(`Scanning skills directory: ${skillsDir}`); | ||
| // Get source info for this directory (default to local if not in map) | ||
| const dirSource = sourceMap[skillsDir] || DEFAULT_SKILL_SOURCE; | ||
| // Check if the directory itself contains skills | ||
| const dirSkills = discoverSkills(skillsDir); | ||
| const dirSkills = discoverSkills(skillsDir, dirSource); | ||
| // Also check standard subdirectories | ||
@@ -94,3 +207,5 @@ for (const subdir of SKILL_SUBDIRS) { | ||
| if (fs.existsSync(subPath)) { | ||
| dirSkills.push(...discoverSkills(subPath)); | ||
| // Use subpath source if available, otherwise inherit from parent | ||
| const subSource = sourceMap[subPath] || dirSource; | ||
| dirSkills.push(...discoverSkills(subPath, subSource)); | ||
| } | ||
@@ -123,9 +238,13 @@ } | ||
| * @param skillTool - The registered skill tool to update | ||
| * @param promptRegistry - For refreshing skill prompts | ||
| * @param subscriptionManager - For refreshing resource subscriptions | ||
| */ | ||
| function refreshSkills(skillsDirs, server, skillTool, subscriptionManager) { | ||
| function refreshSkills(skillsDirs, server, skillTool, promptRegistry, subscriptionManager) { | ||
| console.error("Refreshing skills..."); | ||
| // Re-discover all skills | ||
| const skills = discoverSkillsFromDirs(skillsDirs); | ||
| // Re-discover all skills using current source map | ||
| let skills = discoverSkillsFromDirs(skillsDirs, currentSourceMap); | ||
| const oldCount = skillState.skillMap.size; | ||
| // Apply invocation overrides from config | ||
| const overrides = getSkillInvocationOverrides(); | ||
| skills = applyInvocationOverrides(skills, overrides); | ||
| // Update shared state | ||
@@ -138,2 +257,4 @@ skillState.skillMap = createSkillMap(skills); | ||
| }); | ||
| // Refresh prompts to match new skill state | ||
| refreshPrompts(server, skillState, promptRegistry); | ||
| // Refresh resource subscriptions to match new skill state | ||
@@ -159,5 +280,6 @@ refreshSubscriptions(subscriptionManager, skillState, (uri) => { | ||
| * @param skillTool - The registered skill tool to update | ||
| * @param promptRegistry - For refreshing skill prompts | ||
| * @param subscriptionManager - For refreshing subscriptions | ||
| */ | ||
| function watchSkillDirectories(skillsDirs, server, skillTool, subscriptionManager) { | ||
| function watchSkillDirectories(skillsDirs, server, skillTool, promptRegistry, subscriptionManager) { | ||
| let refreshTimeout = null; | ||
@@ -170,3 +292,3 @@ const debouncedRefresh = () => { | ||
| refreshTimeout = null; | ||
| refreshSkills(skillsDirs, server, skillTool, subscriptionManager); | ||
| refreshSkills(skillsDirs, server, skillTool, promptRegistry, subscriptionManager); | ||
| }, SKILL_REFRESH_DEBOUNCE_MS); | ||
@@ -243,16 +365,72 @@ }; | ||
| async function main() { | ||
| const skillsDirs = getSkillsDirs(); | ||
| if (skillsDirs.length === 0) { | ||
| console.error("No skills directory configured."); | ||
| console.error("Usage: skilljack-mcp /path/to/skills [/path/to/more/skills ...]"); | ||
| console.error(" or: SKILLS_DIR=/path/to/skills skilljack-mcp"); | ||
| console.error(" or: SKILLS_DIR=/path1,/path2 skilljack-mcp"); | ||
| process.exit(1); | ||
| // Check if static mode is enabled | ||
| const isStatic = getStaticMode(); | ||
| // Get skill directories from CLI args, env var, or config file | ||
| // This returns paths that may include GitHub URLs | ||
| const allPaths = getActiveDirectories(); | ||
| // Classify paths as local or GitHub | ||
| const { localDirs, githubSpecs } = classifyPaths(allPaths); | ||
| // Get GitHub configuration | ||
| const githubConfig = getGitHubConfig(); | ||
| // Sync GitHub repositories | ||
| let githubDirs = []; | ||
| if (githubSpecs.length > 0) { | ||
| console.error(`GitHub repos: ${githubSpecs.map((s) => `${s.owner}/${s.repo}`).join(", ")}`); | ||
| // Filter by allowlist | ||
| for (const spec of githubSpecs) { | ||
| if (!isRepoAllowed(spec, githubConfig)) { | ||
| console.error(`Blocked: ${spec.owner}/${spec.repo} not in allowed orgs/users. ` + | ||
| `Set GITHUB_ALLOWED_ORGS or GITHUB_ALLOWED_USERS to permit.`); | ||
| continue; | ||
| } | ||
| currentGithubSpecs.push(spec); | ||
| } | ||
| if (currentGithubSpecs.length > 0) { | ||
| console.error(`Syncing ${currentGithubSpecs.length} GitHub repo(s)...`); | ||
| const syncOptions = { | ||
| cacheDir: githubConfig.cacheDir, | ||
| token: githubConfig.token, | ||
| shallowClone: true, | ||
| }; | ||
| const results = await syncAllRepos(currentGithubSpecs, syncOptions); | ||
| // Collect successful sync paths | ||
| for (const result of results) { | ||
| if (!result.error) { | ||
| githubDirs.push(result.localPath); | ||
| } | ||
| } | ||
| console.error(`Successfully synced ${githubDirs.length}/${currentGithubSpecs.length} repo(s)`); | ||
| } | ||
| } | ||
| console.error(`Skills directories: ${skillsDirs.join(", ")}`); | ||
| // Get bundled skills directory (ships with the package) | ||
| const bundledSkillsDir = getBundledSkillsDir(); | ||
| const hasBundledSkills = fs.existsSync(bundledSkillsDir); | ||
| // Combine all skill directories | ||
| // User directories come first so they can override bundled skills (first-wins deduplication) | ||
| currentSkillsDirs = [...localDirs, ...githubDirs, ...(hasBundledSkills ? [bundledSkillsDir] : [])]; | ||
| // Build source map for skill discovery | ||
| currentSourceMap = buildDirectorySourceMap(localDirs, currentGithubSpecs, githubConfig.cacheDir, hasBundledSkills ? bundledSkillsDir : undefined); | ||
| // Log configured directories | ||
| if (localDirs.length > 0) { | ||
| console.error(`Local directories: ${localDirs.join(", ")}`); | ||
| } | ||
| if (githubDirs.length > 0) { | ||
| console.error(`GitHub cache directories: ${githubDirs.join(", ")}`); | ||
| } | ||
| if (hasBundledSkills) { | ||
| console.error(`Bundled skills: ${bundledSkillsDir}`); | ||
| } | ||
| if (isStatic) { | ||
| console.error("Static mode enabled - skills list frozen at startup"); | ||
| } | ||
| // Discover skills at startup | ||
| const skills = discoverSkillsFromDirs(skillsDirs); | ||
| let skills = discoverSkillsFromDirs(currentSkillsDirs, currentSourceMap); | ||
| // Apply invocation overrides from config | ||
| const overrides = getSkillInvocationOverrides(); | ||
| skills = applyInvocationOverrides(skills, overrides); | ||
| skillState.skillMap = createSkillMap(skills); | ||
| console.error(`Discovered ${skills.length} skill(s)`); | ||
| // Create the MCP server | ||
| // In static mode, disable listChanged for tools/prompts (skills list is frozen) | ||
| // Resource subscriptions remain dynamic for individual skill file watching | ||
| const server = new McpServer({ | ||
@@ -263,13 +441,91 @@ name: "skilljack-mcp", | ||
| capabilities: { | ||
| tools: { listChanged: true }, | ||
| tools: { listChanged: !isStatic }, | ||
| resources: { subscribe: true, listChanged: true }, | ||
| prompts: { listChanged: !isStatic }, | ||
| }, | ||
| }); | ||
| // Register tools and resources | ||
| // Register tools, resources, and prompts | ||
| const skillTool = registerSkillTool(server, skillState); | ||
| registerSkillResources(server, skillState); | ||
| const promptRegistry = registerSkillPrompts(server, skillState); | ||
| // Register subscription handlers for resource file watching | ||
| registerSubscriptionHandlers(server, skillState, subscriptionManager); | ||
| // Set up file watchers for skill directory changes | ||
| watchSkillDirectories(skillsDirs, server, skillTool, subscriptionManager); | ||
| // Register skill-config tool for UI-based directory configuration | ||
| // Skip in static mode since skills list is frozen | ||
| if (!isStatic) { | ||
| registerSkillConfigTool(server, skillState, async () => { | ||
| // Callback when directories or GitHub settings change via UI | ||
| // Reload directories from config and refresh skills | ||
| const newPaths = getActiveDirectories(); | ||
| const { localDirs: newLocalDirs, githubSpecs: newGithubSpecs } = classifyPaths(newPaths); | ||
| // Get fresh GitHub config (in case allowed orgs/users changed) | ||
| const freshGithubConfig = getGitHubConfig(); | ||
| // Filter GitHub specs by allowlist and sync | ||
| const allowedGithubSpecs = []; | ||
| for (const spec of newGithubSpecs) { | ||
| if (isRepoAllowed(spec, freshGithubConfig)) { | ||
| allowedGithubSpecs.push(spec); | ||
| } | ||
| else { | ||
| console.error(`Blocked: ${spec.owner}/${spec.repo} not in allowed orgs/users.`); | ||
| } | ||
| } | ||
| // Sync any GitHub repos | ||
| let newGithubDirs = []; | ||
| if (allowedGithubSpecs.length > 0) { | ||
| console.error(`Syncing ${allowedGithubSpecs.length} GitHub repo(s)...`); | ||
| const syncOptions = { | ||
| cacheDir: freshGithubConfig.cacheDir, | ||
| token: freshGithubConfig.token, | ||
| shallowClone: true, | ||
| }; | ||
| const results = await syncAllRepos(allowedGithubSpecs, syncOptions); | ||
| for (const result of results) { | ||
| if (!result.error) { | ||
| newGithubDirs.push(result.localPath); | ||
| } | ||
| } | ||
| console.error(`Successfully synced ${newGithubDirs.length}/${allowedGithubSpecs.length} repo(s)`); | ||
| } | ||
| // Update current state | ||
| currentGithubSpecs = allowedGithubSpecs; | ||
| githubDirs = newGithubDirs; | ||
| // Include bundled skills (last, so user skills take precedence) | ||
| currentSkillsDirs = [...newLocalDirs, ...newGithubDirs, ...(hasBundledSkills ? [bundledSkillsDir] : [])]; | ||
| currentSourceMap = buildDirectorySourceMap(newLocalDirs, allowedGithubSpecs, freshGithubConfig.cacheDir, hasBundledSkills ? bundledSkillsDir : undefined); | ||
| console.error(`Config changed via UI. Directories: ${currentSkillsDirs.join(", ") || "(none)"}`); | ||
| refreshSkills(currentSkillsDirs, server, skillTool, promptRegistry, subscriptionManager); | ||
| }); | ||
| // Register skill-display tool for UI-based invocation settings | ||
| registerSkillDisplayTool(server, skillState, () => { | ||
| // Callback when invocation settings change via UI | ||
| // Refresh skills to apply new overrides | ||
| console.error("Invocation settings changed via UI. Refreshing skills..."); | ||
| refreshSkills(currentSkillsDirs, server, skillTool, promptRegistry, subscriptionManager); | ||
| }); | ||
| } | ||
| // Set up file watchers for skill directory changes (skip in static mode) | ||
| if (!isStatic && currentSkillsDirs.length > 0) { | ||
| watchSkillDirectories(currentSkillsDirs, server, skillTool, promptRegistry, subscriptionManager); | ||
| } | ||
| // Set up GitHub polling for updates (skip in static mode) | ||
| let pollingManager = null; | ||
| if (!isStatic && currentGithubSpecs.length > 0 && githubConfig.pollIntervalMs > 0) { | ||
| const syncOptions = { | ||
| cacheDir: githubConfig.cacheDir, | ||
| token: githubConfig.token, | ||
| shallowClone: true, | ||
| }; | ||
| pollingManager = createPollingManager(currentGithubSpecs, syncOptions, { | ||
| intervalMs: githubConfig.pollIntervalMs, | ||
| onUpdate: (spec, result) => { | ||
| console.error(`GitHub update detected for ${spec.owner}/${spec.repo}`); | ||
| refreshSkills(currentSkillsDirs, server, skillTool, promptRegistry, subscriptionManager); | ||
| }, | ||
| onError: (spec, error) => { | ||
| console.error(`GitHub polling error for ${spec.owner}/${spec.repo}: ${error.message}`); | ||
| }, | ||
| }); | ||
| pollingManager.start(); | ||
| } | ||
| // Connect via stdio transport | ||
@@ -276,0 +532,0 @@ const transport = new StdioServerTransport(); |
@@ -8,2 +8,20 @@ /** | ||
| /** | ||
| * Source information for a skill. | ||
| * Indicates whether the skill comes from a local directory or GitHub repository. | ||
| */ | ||
| export interface SkillSource { | ||
| type: "local" | "github" | "bundled"; | ||
| displayName: string; | ||
| owner?: string; | ||
| repo?: string; | ||
| } | ||
| /** | ||
| * Default source for skills discovered without explicit source info. | ||
| */ | ||
| export declare const DEFAULT_SKILL_SOURCE: SkillSource; | ||
| /** | ||
| * Source for skills bundled with the server package. | ||
| */ | ||
| export declare const BUNDLED_SKILL_SOURCE: SkillSource; | ||
| /** | ||
| * Metadata extracted from a skill's SKILL.md frontmatter. | ||
@@ -15,2 +33,9 @@ */ | ||
| path: string; | ||
| disableModelInvocation?: boolean; | ||
| userInvocable?: boolean; | ||
| effectiveAssistantInvocable: boolean; | ||
| effectiveUserInvocable: boolean; | ||
| isAssistantOverridden: boolean; | ||
| isUserOverridden: boolean; | ||
| source: SkillSource; | ||
| } | ||
@@ -20,4 +45,7 @@ /** | ||
| * Scans for subdirectories containing SKILL.md files. | ||
| * | ||
| * @param skillsDir - The directory to scan for skills | ||
| * @param source - Optional source info to attach to discovered skills | ||
| */ | ||
| export declare function discoverSkills(skillsDir: string): SkillMetadata[]; | ||
| export declare function discoverSkills(skillsDir: string, source?: SkillSource): SkillMetadata[]; | ||
| /** | ||
@@ -37,1 +65,24 @@ * Generate the server instructions with available skills. | ||
| export declare function createSkillMap(skills: SkillMetadata[]): Map<string, SkillMetadata>; | ||
| /** | ||
| * Invocation override settings per skill (imported type reference). | ||
| */ | ||
| interface SkillInvocationOverrides { | ||
| assistant?: boolean; | ||
| user?: boolean; | ||
| } | ||
| /** | ||
| * Apply invocation overrides from config to compute effective values. | ||
| * Returns a new array with updated effective* fields. | ||
| */ | ||
| export declare function applyInvocationOverrides(skills: SkillMetadata[], overrides: Record<string, SkillInvocationOverrides>): SkillMetadata[]; | ||
| /** | ||
| * Filter skills that can be invoked by the model (appear in tool description). | ||
| * Uses effective value which considers config overrides. | ||
| */ | ||
| export declare function getModelInvocableSkills(skills: SkillMetadata[]): SkillMetadata[]; | ||
| /** | ||
| * Filter skills that can be invoked by the user (appear in prompts menu). | ||
| * Uses effective value which considers config overrides. | ||
| */ | ||
| export declare function getUserInvocableSkills(skills: SkillMetadata[]): SkillMetadata[]; | ||
| export {}; |
@@ -11,2 +11,16 @@ /** | ||
| /** | ||
| * Default source for skills discovered without explicit source info. | ||
| */ | ||
| export const DEFAULT_SKILL_SOURCE = { | ||
| type: "local", | ||
| displayName: "Local", | ||
| }; | ||
| /** | ||
| * Source for skills bundled with the server package. | ||
| */ | ||
| export const BUNDLED_SKILL_SOURCE = { | ||
| type: "bundled", | ||
| displayName: "Bundled", | ||
| }; | ||
| /** | ||
| * Find the SKILL.md file in a skill directory. | ||
@@ -47,4 +61,7 @@ * Prefers SKILL.md (uppercase) but accepts skill.md (lowercase). | ||
| * Scans for subdirectories containing SKILL.md files. | ||
| * | ||
| * @param skillsDir - The directory to scan for skills | ||
| * @param source - Optional source info to attach to discovered skills | ||
| */ | ||
| export function discoverSkills(skillsDir) { | ||
| export function discoverSkills(skillsDir, source) { | ||
| const skills = []; | ||
@@ -68,2 +85,4 @@ if (!fs.existsSync(skillsDir)) { | ||
| const description = metadata.description; | ||
| const disableModelInvocation = metadata["disable-model-invocation"]; | ||
| const userInvocable = metadata["user-invocable"]; | ||
| if (typeof name !== "string" || !name.trim()) { | ||
@@ -77,2 +96,4 @@ console.error(`Skill at ${skillDir}: missing or invalid 'name' field`); | ||
| } | ||
| const effectiveAssistant = disableModelInvocation !== true; | ||
| const effectiveUser = userInvocable !== false; | ||
| skills.push({ | ||
@@ -82,2 +103,11 @@ name: name.trim(), | ||
| path: skillMdPath, | ||
| disableModelInvocation: disableModelInvocation === true, | ||
| userInvocable: userInvocable !== false, // Default to true | ||
| // Initialize effective values from frontmatter (overrides applied later) | ||
| effectiveAssistantInvocable: effectiveAssistant, | ||
| effectiveUserInvocable: effectiveUser, | ||
| isAssistantOverridden: false, | ||
| isUserOverridden: false, | ||
| // Source info (local or GitHub) | ||
| source: source || DEFAULT_SKILL_SOURCE, | ||
| }); | ||
@@ -150,1 +180,40 @@ } | ||
| } | ||
| /** | ||
| * Apply invocation overrides from config to compute effective values. | ||
| * Returns a new array with updated effective* fields. | ||
| */ | ||
| export function applyInvocationOverrides(skills, overrides) { | ||
| return skills.map((skill) => { | ||
| const override = overrides[skill.name]; | ||
| if (!override) { | ||
| return skill; // No override, keep frontmatter defaults | ||
| } | ||
| const hasAssistantOverride = override.assistant !== undefined; | ||
| const hasUserOverride = override.user !== undefined; | ||
| return { | ||
| ...skill, | ||
| effectiveAssistantInvocable: hasAssistantOverride | ||
| ? override.assistant | ||
| : !skill.disableModelInvocation, | ||
| effectiveUserInvocable: hasUserOverride | ||
| ? override.user | ||
| : skill.userInvocable !== false, | ||
| isAssistantOverridden: hasAssistantOverride, | ||
| isUserOverridden: hasUserOverride, | ||
| }; | ||
| }); | ||
| } | ||
| /** | ||
| * Filter skills that can be invoked by the model (appear in tool description). | ||
| * Uses effective value which considers config overrides. | ||
| */ | ||
| export function getModelInvocableSkills(skills) { | ||
| return skills.filter((skill) => skill.effectiveAssistantInvocable); | ||
| } | ||
| /** | ||
| * Filter skills that can be invoked by the user (appear in prompts menu). | ||
| * Uses effective value which considers config overrides. | ||
| */ | ||
| export function getUserInvocableSkills(skills) { | ||
| return skills.filter((skill) => skill.effectiveUserInvocable); | ||
| } |
@@ -16,2 +16,5 @@ /** | ||
| perSkillPrompts: Map<string, RegisteredPrompt>; | ||
| disabledPrompts: Map<string, RegisteredPrompt>; | ||
| skillsPrompt: RegisteredPrompt; | ||
| skillConfigPrompt: RegisteredPrompt; | ||
| } | ||
@@ -21,2 +24,3 @@ /** | ||
| * Includes available skills list for discoverability. | ||
| * Only includes user-invocable skills (excludes user-invocable: false). | ||
| */ | ||
@@ -23,0 +27,0 @@ export declare function getPromptDescription(skillState: SkillState): string; |
+62
-10
@@ -10,3 +10,3 @@ /** | ||
| import { z } from "zod"; | ||
| import { loadSkillContent, generateInstructions } from "./skill-discovery.js"; | ||
| import { loadSkillContent, generateInstructions, getUserInvocableSkills } from "./skill-discovery.js"; | ||
| /** | ||
@@ -23,7 +23,9 @@ * Auto-completion for /skill prompt name argument. | ||
| * Includes available skills list for discoverability. | ||
| * Only includes user-invocable skills (excludes user-invocable: false). | ||
| */ | ||
| export function getPromptDescription(skillState) { | ||
| const skills = Array.from(skillState.skillMap.values()); | ||
| const allSkills = Array.from(skillState.skillMap.values()); | ||
| const userInvocableSkills = getUserInvocableSkills(allSkills); | ||
| const usage = "Load a skill by name with auto-completion.\n\n"; | ||
| return usage + generateInstructions(skills); | ||
| return usage + generateInstructions(userInvocableSkills); | ||
| } | ||
@@ -102,6 +104,43 @@ /** | ||
| }); | ||
| // 2. Register per-skill prompts (no arguments needed) | ||
| // 2. Register /skills prompt (opens skill-display UI) | ||
| const skillsPrompt = server.registerPrompt("skills", { | ||
| title: "View Skills", | ||
| description: "Open the skills list UI to view all available skills and manage their invocation settings.", | ||
| }, async () => { | ||
| return { | ||
| messages: [ | ||
| { | ||
| role: "user", | ||
| content: { | ||
| type: "text", | ||
| text: "Please open the skills display UI using the skill-display tool so I can view and manage my skills.", | ||
| }, | ||
| }, | ||
| ], | ||
| }; | ||
| }); | ||
| // 3. Register /skill-config prompt (opens config UI) | ||
| const skillConfigPrompt = server.registerPrompt("skill-config", { | ||
| title: "Configure Skills", | ||
| description: "Open the skills configuration UI to manage skill directories and GitHub sources.", | ||
| }, async () => { | ||
| return { | ||
| messages: [ | ||
| { | ||
| role: "user", | ||
| content: { | ||
| type: "text", | ||
| text: "Please open the skills configuration UI using the skill-config tool so I can manage my skill directories.", | ||
| }, | ||
| }, | ||
| ], | ||
| }; | ||
| }); | ||
| // 4. Register per-skill prompts (no arguments needed) | ||
| // Returns embedded resource with skill:// URI (MCP-idiomatic) | ||
| // Only register prompts for user-invocable skills (excludes user-invocable: false) | ||
| const perSkillPrompts = new Map(); | ||
| for (const [name, skill] of skillState.skillMap) { | ||
| const userInvocableSkills = getUserInvocableSkills(Array.from(skillState.skillMap.values())); | ||
| for (const skill of userInvocableSkills) { | ||
| const name = skill.name; | ||
| // Capture skill info in closure for this specific prompt | ||
@@ -154,3 +193,3 @@ const skillPath = skill.path; | ||
| } | ||
| return { skillPrompt, perSkillPrompts }; | ||
| return { skillPrompt, perSkillPrompts, disabledPrompts: new Map(), skillsPrompt, skillConfigPrompt }; | ||
| } | ||
@@ -175,11 +214,17 @@ /** | ||
| }); | ||
| // Disable removed skill prompts | ||
| // Get current user-invocable skills | ||
| const userInvocableSkills = getUserInvocableSkills(Array.from(skillState.skillMap.values())); | ||
| const userInvocableNames = new Set(userInvocableSkills.map((s) => s.name)); | ||
| // Disable prompts for removed skills or skills no longer user-invocable | ||
| for (const [name, prompt] of registry.perSkillPrompts) { | ||
| if (!skillState.skillMap.has(name)) { | ||
| if (!userInvocableNames.has(name)) { | ||
| prompt.update({ enabled: false }); | ||
| // Move to disabled map so we can re-enable later if needed | ||
| registry.disabledPrompts.set(name, prompt); | ||
| registry.perSkillPrompts.delete(name); | ||
| } | ||
| } | ||
| // Add/update per-skill prompts | ||
| for (const [name, skill] of skillState.skillMap) { | ||
| // Add/update per-skill prompts for user-invocable skills only | ||
| for (const skill of userInvocableSkills) { | ||
| const name = skill.name; | ||
| if (registry.perSkillPrompts.has(name)) { | ||
@@ -191,2 +236,9 @@ // Update existing prompt description | ||
| } | ||
| else if (registry.disabledPrompts.has(name)) { | ||
| // Re-enable previously disabled prompt | ||
| const prompt = registry.disabledPrompts.get(name); | ||
| prompt.update({ enabled: true, description: skill.description }); | ||
| registry.perSkillPrompts.set(name, prompt); | ||
| registry.disabledPrompts.delete(name); | ||
| } | ||
| else { | ||
@@ -193,0 +245,0 @@ // Register new skill prompt with embedded resource |
@@ -11,5 +11,8 @@ /** | ||
| * URI Scheme: | ||
| * skill://{skillName} -> SKILL.md content (template) | ||
| * skill://{skillName}/ -> Collection: all files in skill directory | ||
| * skill://{skillName}/{path} -> File within skill directory (template) | ||
| * skill://{skillName} -> SKILL.md content (template) | ||
| * skill://{skillName}/ -> Collection: all files in skill directory | ||
| * | ||
| * Note: Individual file URIs (skill://{skillName}/{path}) are not listed | ||
| * as resources to reduce noise. Use the skill-resource tool to fetch | ||
| * specific files on demand. | ||
| */ | ||
@@ -16,0 +19,0 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
+10
-94
@@ -11,5 +11,8 @@ /** | ||
| * URI Scheme: | ||
| * skill://{skillName} -> SKILL.md content (template) | ||
| * skill://{skillName}/ -> Collection: all files in skill directory | ||
| * skill://{skillName}/{path} -> File within skill directory (template) | ||
| * skill://{skillName} -> SKILL.md content (template) | ||
| * skill://{skillName}/ -> Collection: all files in skill directory | ||
| * | ||
| * Note: Individual file URIs (skill://{skillName}/{path}) are not listed | ||
| * as resources to reduce noise. Use the skill-resource tool to fetch | ||
| * specific files on demand. | ||
| */ | ||
@@ -54,6 +57,7 @@ import * as fs from "node:fs"; | ||
| registerSkillTemplate(server, skillState); | ||
| // Register collection resource for skill directories (must be before file template) | ||
| // Register collection resource for skill directories | ||
| registerSkillDirectoryCollection(server, skillState); | ||
| // Register resource template for skill files | ||
| registerSkillFileTemplate(server, skillState); | ||
| // Note: Individual file resources (skill://{name}/{path}) are intentionally | ||
| // not registered to reduce noise. Use the skill-resource tool to fetch | ||
| // specific files on demand. | ||
| } | ||
@@ -201,89 +205,1 @@ /** | ||
| } | ||
| /** | ||
| * Register the resource template for accessing files within skills. | ||
| * | ||
| * URI Pattern: skill://{skillName}/{filePath} | ||
| */ | ||
| function registerSkillFileTemplate(server, skillState) { | ||
| server.registerResource("Skill File", new ResourceTemplate("skill://{skillName}/{+filePath}", { | ||
| list: async () => { | ||
| // Return all listable skill files (dynamic based on current skillMap) | ||
| const resources = []; | ||
| for (const [name, skill] of skillState.skillMap) { | ||
| const skillDir = path.dirname(skill.path); | ||
| const files = listSkillFiles(skillDir); | ||
| for (const file of files) { | ||
| const uri = `skill://${encodeURIComponent(name)}/${file}`; | ||
| resources.push({ | ||
| uri, | ||
| name: `${name}/${file}`, | ||
| mimeType: getMimeType(file), | ||
| }); | ||
| } | ||
| } | ||
| return { resources }; | ||
| }, | ||
| complete: { | ||
| skillName: (value) => { | ||
| const names = Array.from(skillState.skillMap.keys()); | ||
| return names.filter((name) => name.toLowerCase().startsWith(value.toLowerCase())); | ||
| }, | ||
| }, | ||
| }), { | ||
| mimeType: "text/plain", | ||
| description: "Files within a skill directory (scripts, snippets, assets, etc.)", | ||
| }, async (resourceUri, variables) => { | ||
| // Extract skill name and file path from URI | ||
| const uriStr = resourceUri.toString(); | ||
| const match = uriStr.match(/^skill:\/\/([^/]+)\/(.+)$/); | ||
| if (!match) { | ||
| throw new Error(`Invalid skill file URI: ${uriStr}`); | ||
| } | ||
| const skillName = decodeURIComponent(match[1]); | ||
| const filePath = match[2]; | ||
| const skill = skillState.skillMap.get(skillName); | ||
| if (!skill) { | ||
| const available = Array.from(skillState.skillMap.keys()).join(", "); | ||
| throw new Error(`Skill "${skillName}" not found. Available: ${available || "none"}`); | ||
| } | ||
| const skillDir = path.dirname(skill.path); | ||
| const fullPath = path.resolve(skillDir, filePath); | ||
| // Security: Validate path is within skill directory | ||
| if (!isPathWithinBase(fullPath, skillDir)) { | ||
| throw new Error(`Path "${filePath}" is outside the skill directory`); | ||
| } | ||
| // Check file exists | ||
| if (!fs.existsSync(fullPath)) { | ||
| const files = listSkillFiles(skillDir).slice(0, 10); | ||
| throw new Error(`File "${filePath}" not found in skill "${skillName}". ` + | ||
| `Available: ${files.join(", ")}${files.length >= 10 ? "..." : ""}`); | ||
| } | ||
| const stat = fs.statSync(fullPath); | ||
| // Reject symlinks | ||
| if (stat.isSymbolicLink()) { | ||
| throw new Error(`Cannot read symlink "${filePath}"`); | ||
| } | ||
| // Reject directories | ||
| if (stat.isDirectory()) { | ||
| const files = listSkillFiles(skillDir, filePath); | ||
| throw new Error(`"${filePath}" is a directory. Files within: ${files.join(", ")}`); | ||
| } | ||
| // Check file size | ||
| if (stat.size > MAX_FILE_SIZE) { | ||
| const sizeMB = (stat.size / 1024 / 1024).toFixed(2); | ||
| throw new Error(`File too large (${sizeMB}MB). Maximum: 10MB`); | ||
| } | ||
| // Read and return content | ||
| const content = fs.readFileSync(fullPath, "utf-8"); | ||
| const mimeType = getMimeType(fullPath); | ||
| return { | ||
| contents: [ | ||
| { | ||
| uri: uriStr, | ||
| mimeType, | ||
| text: content, | ||
| }, | ||
| ], | ||
| }; | ||
| }); | ||
| } |
@@ -13,3 +13,3 @@ /** | ||
| import { z } from "zod"; | ||
| import { loadSkillContent, generateInstructions } from "./skill-discovery.js"; | ||
| import { loadSkillContent, generateInstructions, getModelInvocableSkills } from "./skill-discovery.js"; | ||
| /** | ||
@@ -42,4 +42,5 @@ * Input schema for the skill tool. | ||
| "generating any other response about the task.\n\n"; | ||
| const skills = Array.from(skillState.skillMap.values()); | ||
| return usage + generateInstructions(skills); | ||
| const allSkills = Array.from(skillState.skillMap.values()); | ||
| const modelInvocableSkills = getModelInvocableSkills(allSkills); | ||
| return usage + generateInstructions(modelInvocableSkills); | ||
| } | ||
@@ -46,0 +47,0 @@ export function registerSkillTool(server, skillState) { |
@@ -12,3 +12,3 @@ /** | ||
| * - skill://{name}/ → Watch entire skill directory (directory collection) | ||
| * - skill://{name}/{path} → Watch specific file | ||
| * - skill://{name}/{path} → Watch specific file (subscribable but not listed as resource) | ||
| */ | ||
@@ -15,0 +15,0 @@ import { FSWatcher } from "chokidar"; |
@@ -12,3 +12,3 @@ /** | ||
| * - skill://{name}/ → Watch entire skill directory (directory collection) | ||
| * - skill://{name}/{path} → Watch specific file | ||
| * - skill://{name}/{path} → Watch specific file (subscribable but not listed as resource) | ||
| */ | ||
@@ -15,0 +15,0 @@ import chokidar from "chokidar"; |
+1
-1
| { | ||
| "name": "@skilljack/mcp", | ||
| "version": "0.7.0", | ||
| "version": "0.7.1", | ||
| "description": "MCP server that discovers and serves Agent Skills. I know kung fu.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 8 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
983431
951.96%34
112.5%5127
180.32%15
114.29%9
800%