Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@skilljack/mcp

Package Overview
Dependencies
Maintainers
1
Versions
11
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@skilljack/mcp - npm Package Compare versions

Comparing version
0.7.0
to
0.7.1
+83
dist/github-config.d.ts
/**
* 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,
},
],
};
});
}

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()}">&#128274;</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 ? "&#10003;" : "&#10007;"}
</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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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);
}
});

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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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;

@@ -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";

@@ -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";

{
"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",