@skilljack/mcp 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * GitHub configuration parsing and URL detection.
3
+ * Handles detection of GitHub URLs, parsing repo specs, and allowlist validation.
4
+ */
5
+ import * as fs from "node:fs";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+ /**
9
+ * Default polling interval: 5 minutes.
10
+ */
11
+ const DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000;
12
+ /**
13
+ * Default cache directory.
14
+ */
15
+ const DEFAULT_CACHE_DIR = path.join(os.homedir(), ".skilljack", "github-cache");
16
+ /**
17
+ * Check if a path is a GitHub URL.
18
+ * Detects paths containing "github.com".
19
+ */
20
+ export function isGitHubUrl(urlOrPath) {
21
+ return urlOrPath.toLowerCase().includes("github.com");
22
+ }
23
+ /**
24
+ * Parse a GitHub URL into a GitHubRepoSpec.
25
+ *
26
+ * Supported formats:
27
+ * github.com/owner/repo
28
+ * github.com/owner/repo@ref
29
+ * github.com/owner/repo/subpath
30
+ * github.com/owner/repo/subpath@ref
31
+ * https://github.com/owner/repo
32
+ * https://github.com/owner/repo.git
33
+ *
34
+ * @param url - The GitHub URL to parse
35
+ * @returns Parsed GitHubRepoSpec
36
+ * @throws Error if URL format is invalid
37
+ */
38
+ export function parseGitHubUrl(url) {
39
+ // Remove protocol prefix if present
40
+ let normalized = url.replace(/^https?:\/\//, "");
41
+ // Remove github.com prefix
42
+ normalized = normalized.replace(/^github\.com\//i, "");
43
+ // Remove trailing .git if present
44
+ normalized = normalized.replace(/\.git$/, "");
45
+ // Extract ref if present (everything after @)
46
+ let ref;
47
+ const atIndex = normalized.lastIndexOf("@");
48
+ if (atIndex !== -1) {
49
+ ref = normalized.slice(atIndex + 1);
50
+ normalized = normalized.slice(0, atIndex);
51
+ }
52
+ // Split remaining path: owner/repo[/subpath...]
53
+ let parts = normalized.split("/").filter((p) => p.length > 0);
54
+ if (parts.length < 2) {
55
+ throw new Error(`Invalid GitHub URL: "${url}". Expected format: github.com/owner/repo[/subpath][@ref]`);
56
+ }
57
+ const owner = parts[0];
58
+ const repo = parts[1];
59
+ // Handle GitHub web URLs with /tree/<ref>/ or /blob/<ref>/ patterns
60
+ // e.g., owner/repo/tree/main/path/to/dir -> extract ref and subpath
61
+ if (parts.length >= 4 && (parts[2] === "tree" || parts[2] === "blob")) {
62
+ // parts[2] is "tree" or "blob", parts[3] is the ref
63
+ if (!ref) {
64
+ ref = parts[3];
65
+ }
66
+ // Everything after the ref is the subpath
67
+ const subpath = parts.length > 4 ? parts.slice(4).join("/") : undefined;
68
+ return { owner, repo, ref, subpath };
69
+ }
70
+ const subpath = parts.length > 2 ? parts.slice(2).join("/") : undefined;
71
+ return { owner, repo, ref, subpath };
72
+ }
73
+ /**
74
+ * Check if a repository is allowed by the allowlist.
75
+ * If no allowlist is configured (both allowedOrgs and allowedUsers empty),
76
+ * all repos are DENIED by default for security.
77
+ *
78
+ * @param spec - The repository specification
79
+ * @param config - GitHub configuration with allowlists
80
+ * @returns true if allowed, false if blocked
81
+ */
82
+ export function isRepoAllowed(spec, config) {
83
+ // If no allowlist configured, deny all for security
84
+ if (config.allowedOrgs.length === 0 && config.allowedUsers.length === 0) {
85
+ return false;
86
+ }
87
+ const ownerLower = spec.owner.toLowerCase();
88
+ // Check if owner is in allowed orgs
89
+ if (config.allowedOrgs.some((org) => org.toLowerCase() === ownerLower)) {
90
+ return true;
91
+ }
92
+ // Check if owner is in allowed users
93
+ if (config.allowedUsers.some((user) => user.toLowerCase() === ownerLower)) {
94
+ return true;
95
+ }
96
+ return false;
97
+ }
98
+ /**
99
+ * Parse a comma-separated list from an environment variable.
100
+ */
101
+ function parseCommaList(envValue) {
102
+ if (!envValue) {
103
+ return [];
104
+ }
105
+ return envValue
106
+ .split(",")
107
+ .map((s) => s.trim())
108
+ .filter((s) => s.length > 0);
109
+ }
110
+ /**
111
+ * Path to the config file.
112
+ */
113
+ const CONFIG_FILE_PATH = path.join(os.homedir(), ".skilljack", "config.json");
114
+ /**
115
+ * Load allowlist from config file.
116
+ * Returns empty arrays if file doesn't exist or can't be parsed.
117
+ */
118
+ function loadAllowlistFromConfig() {
119
+ try {
120
+ if (fs.existsSync(CONFIG_FILE_PATH)) {
121
+ const content = fs.readFileSync(CONFIG_FILE_PATH, "utf-8");
122
+ const config = JSON.parse(content);
123
+ return {
124
+ orgs: Array.isArray(config.githubAllowedOrgs) ? config.githubAllowedOrgs : [],
125
+ users: Array.isArray(config.githubAllowedUsers) ? config.githubAllowedUsers : [],
126
+ };
127
+ }
128
+ }
129
+ catch {
130
+ // Ignore errors reading config file
131
+ }
132
+ return { orgs: [], users: [] };
133
+ }
134
+ /**
135
+ * Get GitHub configuration from environment variables and config file.
136
+ * Environment variables take precedence over config file.
137
+ *
138
+ * Environment variables:
139
+ * GITHUB_TOKEN - Authentication token for private repos
140
+ * GITHUB_POLL_INTERVAL_MS - Polling interval (0 to disable, default 300000)
141
+ * SKILLJACK_CACHE_DIR - Cache directory (default ~/.skilljack/github-cache)
142
+ * GITHUB_ALLOWED_ORGS - Comma-separated list of allowed organizations (overrides config)
143
+ * GITHUB_ALLOWED_USERS - Comma-separated list of allowed users (overrides config)
144
+ */
145
+ export function getGitHubConfig() {
146
+ const pollIntervalStr = process.env.GITHUB_POLL_INTERVAL_MS;
147
+ let pollIntervalMs = DEFAULT_POLL_INTERVAL_MS;
148
+ if (pollIntervalStr !== undefined) {
149
+ const parsed = parseInt(pollIntervalStr, 10);
150
+ if (!isNaN(parsed) && parsed >= 0) {
151
+ pollIntervalMs = parsed;
152
+ }
153
+ }
154
+ // Load allowlist from config file as fallback
155
+ const configAllowlist = loadAllowlistFromConfig();
156
+ // Environment variables override config file
157
+ const envOrgs = parseCommaList(process.env.GITHUB_ALLOWED_ORGS);
158
+ const envUsers = parseCommaList(process.env.GITHUB_ALLOWED_USERS);
159
+ return {
160
+ token: process.env.GITHUB_TOKEN,
161
+ pollIntervalMs,
162
+ cacheDir: process.env.SKILLJACK_CACHE_DIR || DEFAULT_CACHE_DIR,
163
+ allowedOrgs: envOrgs.length > 0 ? envOrgs : configAllowlist.orgs,
164
+ allowedUsers: envUsers.length > 0 ? envUsers : configAllowlist.users,
165
+ };
166
+ }
167
+ /**
168
+ * Get the local cache path for a GitHub repository.
169
+ *
170
+ * @param spec - The repository specification
171
+ * @param cacheDir - Base cache directory
172
+ * @returns Full path to the cached repository (including subpath if specified)
173
+ */
174
+ export function getRepoCachePath(spec, cacheDir) {
175
+ const repoPath = path.join(cacheDir, spec.owner, spec.repo);
176
+ if (spec.subpath) {
177
+ return path.join(repoPath, spec.subpath);
178
+ }
179
+ return repoPath;
180
+ }
181
+ /**
182
+ * Get the local clone path for a GitHub repository (without subpath).
183
+ * This is where the git repository is cloned to.
184
+ *
185
+ * @param spec - The repository specification
186
+ * @param cacheDir - Base cache directory
187
+ * @returns Full path to the cloned repository root
188
+ */
189
+ export function getRepoClonePath(spec, cacheDir) {
190
+ return path.join(cacheDir, spec.owner, spec.repo);
191
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * GitHub repository polling for updates.
3
+ * Periodically checks for changes and triggers sync when updates are available.
4
+ */
5
+ import { GitHubRepoSpec } from "./github-config.js";
6
+ import { SyncOptions, SyncResult } from "./github-sync.js";
7
+ /**
8
+ * Options for the polling manager.
9
+ */
10
+ export interface PollingOptions {
11
+ intervalMs: number;
12
+ onUpdate: (spec: GitHubRepoSpec, result: SyncResult) => void;
13
+ onError?: (spec: GitHubRepoSpec, error: Error) => void;
14
+ }
15
+ /**
16
+ * Polling manager interface.
17
+ */
18
+ export interface PollingManager {
19
+ start(): void;
20
+ stop(): void;
21
+ checkNow(): Promise<void>;
22
+ isRunning(): boolean;
23
+ }
24
+ /**
25
+ * Create a polling manager for GitHub repositories.
26
+ *
27
+ * The manager periodically checks for updates and syncs repositories
28
+ * when changes are detected. Pinned refs (tags, commits) are skipped.
29
+ *
30
+ * @param specs - Repository specifications to poll
31
+ * @param syncOptions - Options for sync operations
32
+ * @param pollingOptions - Polling configuration
33
+ * @returns Polling manager
34
+ */
35
+ export declare function createPollingManager(specs: GitHubRepoSpec[], syncOptions: SyncOptions, pollingOptions: PollingOptions): PollingManager;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * GitHub repository polling for updates.
3
+ * Periodically checks for changes and triggers sync when updates are available.
4
+ */
5
+ import { syncRepo, hasRemoteUpdates } from "./github-sync.js";
6
+ /**
7
+ * Create a polling manager for GitHub repositories.
8
+ *
9
+ * The manager periodically checks for updates and syncs repositories
10
+ * when changes are detected. Pinned refs (tags, commits) are skipped.
11
+ *
12
+ * @param specs - Repository specifications to poll
13
+ * @param syncOptions - Options for sync operations
14
+ * @param pollingOptions - Polling configuration
15
+ * @returns Polling manager
16
+ */
17
+ export function createPollingManager(specs, syncOptions, pollingOptions) {
18
+ let intervalId = null;
19
+ let isChecking = false;
20
+ /**
21
+ * Filter specs to only include those that should be polled.
22
+ * Pinned refs (tags, commits) are excluded.
23
+ */
24
+ function getPolledSpecs() {
25
+ return specs.filter((spec) => {
26
+ if (!spec.ref) {
27
+ return true; // No ref means default branch, poll it
28
+ }
29
+ // Exclude what looks like a tag version or commit hash
30
+ const isVersionTag = /^v?\d+(\.\d+)*/.test(spec.ref);
31
+ const isCommitHash = /^[0-9a-f]{7,40}$/i.test(spec.ref);
32
+ return !isVersionTag && !isCommitHash;
33
+ });
34
+ }
35
+ /**
36
+ * Check all repositories for updates.
37
+ */
38
+ async function checkForUpdates() {
39
+ if (isChecking) {
40
+ console.error("Polling: Already checking for updates, skipping...");
41
+ return;
42
+ }
43
+ isChecking = true;
44
+ const polledSpecs = getPolledSpecs();
45
+ if (polledSpecs.length === 0) {
46
+ console.error("Polling: No repositories to poll (all pinned to specific refs)");
47
+ isChecking = false;
48
+ return;
49
+ }
50
+ console.error(`Polling: Checking ${polledSpecs.length} repo(s) for updates...`);
51
+ for (const spec of polledSpecs) {
52
+ try {
53
+ const hasUpdates = await hasRemoteUpdates(spec, syncOptions);
54
+ if (hasUpdates) {
55
+ console.error(`Polling: Updates available for ${spec.owner}/${spec.repo}`);
56
+ const result = await syncRepo(spec, syncOptions);
57
+ if (!result.error && result.updated) {
58
+ pollingOptions.onUpdate(spec, result);
59
+ }
60
+ }
61
+ }
62
+ catch (error) {
63
+ const err = error instanceof Error ? error : new Error(String(error));
64
+ console.error(`Polling: Error checking ${spec.owner}/${spec.repo}: ${err.message}`);
65
+ pollingOptions.onError?.(spec, err);
66
+ }
67
+ }
68
+ isChecking = false;
69
+ }
70
+ return {
71
+ start() {
72
+ if (intervalId !== null) {
73
+ console.error("Polling: Already running");
74
+ return;
75
+ }
76
+ if (pollingOptions.intervalMs <= 0) {
77
+ console.error("Polling: Disabled (interval <= 0)");
78
+ return;
79
+ }
80
+ const polledSpecs = getPolledSpecs();
81
+ if (polledSpecs.length === 0) {
82
+ console.error("Polling: Not starting (all repos pinned to specific refs)");
83
+ return;
84
+ }
85
+ console.error(`Polling: Starting with ${pollingOptions.intervalMs}ms interval ` +
86
+ `for ${polledSpecs.length} repo(s)`);
87
+ intervalId = setInterval(() => {
88
+ checkForUpdates().catch((error) => {
89
+ console.error(`Polling: Unexpected error: ${error}`);
90
+ });
91
+ }, pollingOptions.intervalMs);
92
+ },
93
+ stop() {
94
+ if (intervalId === null) {
95
+ return;
96
+ }
97
+ console.error("Polling: Stopping");
98
+ clearInterval(intervalId);
99
+ intervalId = null;
100
+ },
101
+ async checkNow() {
102
+ await checkForUpdates();
103
+ },
104
+ isRunning() {
105
+ return intervalId !== null;
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * GitHub repository synchronization.
3
+ * Handles cloning and pulling repositories to local cache.
4
+ */
5
+ import { GitHubRepoSpec } from "./github-config.js";
6
+ /**
7
+ * Options for syncing GitHub repositories.
8
+ */
9
+ export interface SyncOptions {
10
+ cacheDir: string;
11
+ token?: string;
12
+ shallowClone?: boolean;
13
+ }
14
+ /**
15
+ * Result of a sync operation.
16
+ */
17
+ export interface SyncResult {
18
+ spec: GitHubRepoSpec;
19
+ localPath: string;
20
+ clonePath: string;
21
+ updated: boolean;
22
+ error?: string;
23
+ }
24
+ /**
25
+ * Sync a single GitHub repository.
26
+ * Clones if not present, pulls if already cloned.
27
+ *
28
+ * @param spec - Repository specification
29
+ * @param options - Sync options
30
+ * @returns Sync result
31
+ */
32
+ export declare function syncRepo(spec: GitHubRepoSpec, options: SyncOptions): Promise<SyncResult>;
33
+ /**
34
+ * Sync multiple GitHub repositories.
35
+ *
36
+ * @param specs - Repository specifications
37
+ * @param options - Sync options
38
+ * @returns Array of sync results
39
+ */
40
+ export declare function syncAllRepos(specs: GitHubRepoSpec[], options: SyncOptions): Promise<SyncResult[]>;
41
+ /**
42
+ * Check if a repository has remote updates available.
43
+ * Uses git fetch --dry-run to check without downloading.
44
+ *
45
+ * @param spec - Repository specification
46
+ * @param options - Sync options
47
+ * @returns true if updates are available
48
+ */
49
+ export declare function hasRemoteUpdates(spec: GitHubRepoSpec, options: SyncOptions): Promise<boolean>;
@@ -0,0 +1,259 @@
1
+ /**
2
+ * GitHub repository synchronization.
3
+ * Handles cloning and pulling repositories to local cache.
4
+ */
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import { simpleGit } from "simple-git";
8
+ import { getRepoClonePath, getRepoCachePath } from "./github-config.js";
9
+ /**
10
+ * Maximum retry attempts for network operations.
11
+ */
12
+ const MAX_RETRIES = 3;
13
+ /**
14
+ * Initial backoff delay in milliseconds.
15
+ */
16
+ const INITIAL_BACKOFF_MS = 1000;
17
+ /**
18
+ * Sleep for a given number of milliseconds.
19
+ */
20
+ function sleep(ms) {
21
+ return new Promise((resolve) => setTimeout(resolve, ms));
22
+ }
23
+ /**
24
+ * Build the HTTPS URL for a GitHub repository.
25
+ * Includes token for authentication if provided.
26
+ */
27
+ function buildRepoUrl(spec, token) {
28
+ if (token) {
29
+ return `https://${token}@github.com/${spec.owner}/${spec.repo}.git`;
30
+ }
31
+ return `https://github.com/${spec.owner}/${spec.repo}.git`;
32
+ }
33
+ /**
34
+ * Ensure the parent directory exists.
35
+ */
36
+ function ensureParentDir(filePath) {
37
+ const parentDir = path.dirname(filePath);
38
+ if (!fs.existsSync(parentDir)) {
39
+ fs.mkdirSync(parentDir, { recursive: true });
40
+ }
41
+ }
42
+ /**
43
+ * Check if a directory is a git repository.
44
+ */
45
+ function isGitRepo(dir) {
46
+ return fs.existsSync(path.join(dir, ".git"));
47
+ }
48
+ /**
49
+ * Clone a repository with retry logic.
50
+ */
51
+ async function cloneWithRetry(git, url, destPath, ref, shallowClone) {
52
+ const cloneOptions = [];
53
+ if (shallowClone) {
54
+ cloneOptions.push("--depth", "1");
55
+ }
56
+ if (ref) {
57
+ cloneOptions.push("--branch", ref);
58
+ }
59
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
60
+ try {
61
+ await git.clone(url, destPath, cloneOptions);
62
+ return;
63
+ }
64
+ catch (error) {
65
+ const isLastAttempt = attempt === MAX_RETRIES - 1;
66
+ if (isLastAttempt) {
67
+ throw error;
68
+ }
69
+ const backoff = INITIAL_BACKOFF_MS * Math.pow(2, attempt);
70
+ console.error(`Clone attempt ${attempt + 1}/${MAX_RETRIES} failed, retrying in ${backoff}ms...`);
71
+ await sleep(backoff);
72
+ }
73
+ }
74
+ }
75
+ /**
76
+ * Pull updates with retry logic.
77
+ * Returns true if there were changes.
78
+ */
79
+ async function pullWithRetry(git, ref) {
80
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
81
+ try {
82
+ // Get current HEAD before pull
83
+ const beforeHead = await git.revparse(["HEAD"]);
84
+ // Pull changes
85
+ if (ref) {
86
+ await git.fetch(["origin", ref]);
87
+ await git.checkout(ref);
88
+ await git.pull("origin", ref, ["--ff-only"]);
89
+ }
90
+ else {
91
+ await git.pull(["--ff-only"]);
92
+ }
93
+ // Check if HEAD changed
94
+ const afterHead = await git.revparse(["HEAD"]);
95
+ return beforeHead !== afterHead;
96
+ }
97
+ catch (error) {
98
+ const isLastAttempt = attempt === MAX_RETRIES - 1;
99
+ if (isLastAttempt) {
100
+ throw error;
101
+ }
102
+ const backoff = INITIAL_BACKOFF_MS * Math.pow(2, attempt);
103
+ console.error(`Pull attempt ${attempt + 1}/${MAX_RETRIES} failed, retrying in ${backoff}ms...`);
104
+ await sleep(backoff);
105
+ }
106
+ }
107
+ return false;
108
+ }
109
+ /**
110
+ * Sync a single GitHub repository.
111
+ * Clones if not present, pulls if already cloned.
112
+ *
113
+ * @param spec - Repository specification
114
+ * @param options - Sync options
115
+ * @returns Sync result
116
+ */
117
+ export async function syncRepo(spec, options) {
118
+ const clonePath = getRepoClonePath(spec, options.cacheDir);
119
+ const localPath = getRepoCachePath(spec, options.cacheDir);
120
+ const shallowClone = options.shallowClone !== false; // Default true
121
+ const result = {
122
+ spec,
123
+ localPath,
124
+ clonePath,
125
+ updated: false,
126
+ };
127
+ try {
128
+ const url = buildRepoUrl(spec, options.token);
129
+ const git = simpleGit();
130
+ if (!isGitRepo(clonePath)) {
131
+ // Clone the repository
132
+ console.error(`Cloning ${spec.owner}/${spec.repo}...`);
133
+ ensureParentDir(clonePath);
134
+ await cloneWithRetry(git, url, clonePath, spec.ref, shallowClone);
135
+ result.updated = true;
136
+ console.error(`Cloned ${spec.owner}/${spec.repo} to ${clonePath}`);
137
+ }
138
+ else {
139
+ // Pull updates
140
+ console.error(`Pulling updates for ${spec.owner}/${spec.repo}...`);
141
+ const repoGit = simpleGit(clonePath);
142
+ // For pinned refs (tags/commits), we don't pull updates
143
+ if (spec.ref && !spec.ref.includes("/")) {
144
+ // Check if this looks like a tag or commit hash
145
+ const isTag = await repoGit
146
+ .tags()
147
+ .then((tags) => tags.all.includes(spec.ref))
148
+ .catch(() => false);
149
+ const isCommit = /^[0-9a-f]{7,40}$/i.test(spec.ref);
150
+ if (isTag || isCommit) {
151
+ console.error(`Skipping pull for pinned ref: ${spec.ref}`);
152
+ return result;
153
+ }
154
+ }
155
+ result.updated = await pullWithRetry(repoGit, spec.ref);
156
+ if (result.updated) {
157
+ console.error(`Updated ${spec.owner}/${spec.repo}`);
158
+ }
159
+ else {
160
+ console.error(`${spec.owner}/${spec.repo} is up to date`);
161
+ }
162
+ }
163
+ // Verify the subpath exists if specified
164
+ if (spec.subpath && !fs.existsSync(localPath)) {
165
+ result.error = `Subpath "${spec.subpath}" not found in repository`;
166
+ console.error(`Warning: ${result.error}`);
167
+ }
168
+ }
169
+ catch (error) {
170
+ const errorMessage = error instanceof Error ? error.message : String(error);
171
+ // Provide helpful error messages
172
+ if (errorMessage.includes("Authentication failed") || errorMessage.includes("403")) {
173
+ result.error = `Authentication failed for ${spec.owner}/${spec.repo}. For private repos, set GITHUB_TOKEN environment variable.`;
174
+ }
175
+ else if (errorMessage.includes("rate limit")) {
176
+ result.error = `Rate limited when accessing ${spec.owner}/${spec.repo}. Try again later.`;
177
+ }
178
+ else if (errorMessage.includes("not found") || errorMessage.includes("404")) {
179
+ result.error = `Repository not found: ${spec.owner}/${spec.repo}`;
180
+ }
181
+ else {
182
+ result.error = `Failed to sync ${spec.owner}/${spec.repo}: ${errorMessage}`;
183
+ }
184
+ console.error(result.error);
185
+ }
186
+ return result;
187
+ }
188
+ /**
189
+ * Sync multiple GitHub repositories.
190
+ *
191
+ * @param specs - Repository specifications
192
+ * @param options - Sync options
193
+ * @returns Array of sync results
194
+ */
195
+ export async function syncAllRepos(specs, options) {
196
+ const results = [];
197
+ for (const spec of specs) {
198
+ const result = await syncRepo(spec, options);
199
+ results.push(result);
200
+ }
201
+ return results;
202
+ }
203
+ /**
204
+ * Check if a repository has remote updates available.
205
+ * Uses git fetch --dry-run to check without downloading.
206
+ *
207
+ * @param spec - Repository specification
208
+ * @param options - Sync options
209
+ * @returns true if updates are available
210
+ */
211
+ export async function hasRemoteUpdates(spec, options) {
212
+ const clonePath = getRepoClonePath(spec, options.cacheDir);
213
+ if (!isGitRepo(clonePath)) {
214
+ // Not cloned yet, so yes there are "updates"
215
+ return true;
216
+ }
217
+ // Skip check for pinned refs
218
+ if (spec.ref) {
219
+ const git = simpleGit(clonePath);
220
+ const isTag = await git
221
+ .tags()
222
+ .then((tags) => tags.all.includes(spec.ref))
223
+ .catch(() => false);
224
+ const isCommit = /^[0-9a-f]{7,40}$/i.test(spec.ref);
225
+ if (isTag || isCommit) {
226
+ return false;
227
+ }
228
+ }
229
+ try {
230
+ const git = simpleGit(clonePath);
231
+ const url = buildRepoUrl(spec, options.token);
232
+ // Fetch to update remote refs
233
+ await git.fetch(["origin"]);
234
+ // Compare local and remote
235
+ const localHead = await git.revparse(["HEAD"]);
236
+ const remoteRef = spec.ref ? `origin/${spec.ref}` : "origin/HEAD";
237
+ try {
238
+ const remoteHead = await git.revparse([remoteRef]);
239
+ return localHead !== remoteHead;
240
+ }
241
+ catch {
242
+ // Remote ref might not exist, try origin/main or origin/master
243
+ for (const branch of ["origin/main", "origin/master"]) {
244
+ try {
245
+ const remoteHead = await git.revparse([branch]);
246
+ return localHead !== remoteHead;
247
+ }
248
+ catch {
249
+ continue;
250
+ }
251
+ }
252
+ return false;
253
+ }
254
+ }
255
+ catch (error) {
256
+ console.error(`Failed to check for updates: ${error}`);
257
+ return false;
258
+ }
259
+ }
package/dist/index.d.ts CHANGED
@@ -6,8 +6,17 @@
6
6
  * Provides global skills with tools for progressive disclosure.
7
7
  *
8
8
  * Usage:
9
- * skilljack-mcp /path/to/skills [/path2 ...] # One or more directories
10
- * SKILLS_DIR=/path/to/skills skilljack-mcp # Single directory via env
11
- * SKILLS_DIR=/path1,/path2 skilljack-mcp # Multiple (comma-separated)
9
+ * skilljack-mcp /path/to/skills [/path2 ...] # Local directories
10
+ * skilljack-mcp --static /path/to/skills # Static mode (no file watching)
11
+ * skilljack-mcp github.com/owner/repo # GitHub repository
12
+ * skilljack-mcp /local github.com/owner/repo # Mixed local + GitHub
13
+ * SKILLS_DIR=/path,github.com/owner/repo skilljack-mcp # Via environment
14
+ * SKILLJACK_STATIC=true skilljack-mcp # Static mode via env
15
+ * (or configure local directories via the skill-config UI)
16
+ *
17
+ * Options:
18
+ * --static Freeze skills list at startup. Disables file watching and
19
+ * tools/prompts listChanged notifications. Resource subscriptions
20
+ * remain fully dynamic.
12
21
  */
13
22
  export {};