@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.
- package/README.md +7 -396
- package/dist/github-config.d.ts +83 -0
- package/dist/github-config.js +191 -0
- package/dist/github-polling.d.ts +35 -0
- package/dist/github-polling.js +108 -0
- package/dist/github-sync.d.ts +49 -0
- package/dist/github-sync.js +259 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.js +309 -53
- package/dist/skill-config-tool.d.ts +22 -0
- package/dist/skill-config-tool.js +495 -0
- package/dist/skill-config.d.ts +163 -0
- package/dist/skill-config.js +450 -0
- package/dist/skill-discovery.d.ts +52 -1
- package/dist/skill-discovery.js +70 -1
- package/dist/skill-display-tool.d.ts +22 -0
- package/dist/skill-display-tool.js +302 -0
- package/dist/skill-prompts.d.ts +4 -0
- package/dist/skill-prompts.js +62 -10
- package/dist/skill-resources.d.ts +6 -3
- package/dist/skill-resources.js +10 -94
- package/dist/skill-tool.js +4 -3
- package/dist/subscriptions.d.ts +1 -1
- package/dist/subscriptions.js +1 -1
- package/dist/ui/mcp-app.d.ts +1 -0
- package/dist/ui/mcp-app.html +278 -0
- package/dist/ui/mcp-app.js +484 -0
- package/dist/ui/skill-display.d.ts +1 -0
- package/dist/ui/skill-display.html +188 -0
- package/dist/ui/skill-display.js +269 -0
- package/package.json +2 -1
- package/skills/skilljack-docs/SKILL.md +431 -0
|
@@ -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 ...]
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {};
|