@skilljack/mcp 0.7.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,83 @@
1
+ /**
2
+ * GitHub configuration parsing and URL detection.
3
+ * Handles detection of GitHub URLs, parsing repo specs, and allowlist validation.
4
+ */
5
+ /**
6
+ * Parsed GitHub repository specification.
7
+ */
8
+ export interface GitHubRepoSpec {
9
+ owner: string;
10
+ repo: string;
11
+ ref?: string;
12
+ subpath?: string;
13
+ }
14
+ /**
15
+ * GitHub-specific configuration from environment variables.
16
+ */
17
+ export interface GitHubConfig {
18
+ token?: string;
19
+ pollIntervalMs: number;
20
+ cacheDir: string;
21
+ allowedOrgs: string[];
22
+ allowedUsers: string[];
23
+ }
24
+ /**
25
+ * Check if a path is a GitHub URL.
26
+ * Detects paths containing "github.com".
27
+ */
28
+ export declare function isGitHubUrl(urlOrPath: string): boolean;
29
+ /**
30
+ * Parse a GitHub URL into a GitHubRepoSpec.
31
+ *
32
+ * Supported formats:
33
+ * github.com/owner/repo
34
+ * github.com/owner/repo@ref
35
+ * github.com/owner/repo/subpath
36
+ * github.com/owner/repo/subpath@ref
37
+ * https://github.com/owner/repo
38
+ * https://github.com/owner/repo.git
39
+ *
40
+ * @param url - The GitHub URL to parse
41
+ * @returns Parsed GitHubRepoSpec
42
+ * @throws Error if URL format is invalid
43
+ */
44
+ export declare function parseGitHubUrl(url: string): GitHubRepoSpec;
45
+ /**
46
+ * Check if a repository is allowed by the allowlist.
47
+ * If no allowlist is configured (both allowedOrgs and allowedUsers empty),
48
+ * all repos are DENIED by default for security.
49
+ *
50
+ * @param spec - The repository specification
51
+ * @param config - GitHub configuration with allowlists
52
+ * @returns true if allowed, false if blocked
53
+ */
54
+ export declare function isRepoAllowed(spec: GitHubRepoSpec, config: GitHubConfig): boolean;
55
+ /**
56
+ * Get GitHub configuration from environment variables and config file.
57
+ * Environment variables take precedence over config file.
58
+ *
59
+ * Environment variables:
60
+ * GITHUB_TOKEN - Authentication token for private repos
61
+ * GITHUB_POLL_INTERVAL_MS - Polling interval (0 to disable, default 300000)
62
+ * SKILLJACK_CACHE_DIR - Cache directory (default ~/.skilljack/github-cache)
63
+ * GITHUB_ALLOWED_ORGS - Comma-separated list of allowed organizations (overrides config)
64
+ * GITHUB_ALLOWED_USERS - Comma-separated list of allowed users (overrides config)
65
+ */
66
+ export declare function getGitHubConfig(): GitHubConfig;
67
+ /**
68
+ * Get the local cache path for a GitHub repository.
69
+ *
70
+ * @param spec - The repository specification
71
+ * @param cacheDir - Base cache directory
72
+ * @returns Full path to the cached repository (including subpath if specified)
73
+ */
74
+ export declare function getRepoCachePath(spec: GitHubRepoSpec, cacheDir: string): string;
75
+ /**
76
+ * Get the local clone path for a GitHub repository (without subpath).
77
+ * This is where the git repository is cloned to.
78
+ *
79
+ * @param spec - The repository specification
80
+ * @param cacheDir - Base cache directory
81
+ * @returns Full path to the cloned repository root
82
+ */
83
+ export declare function getRepoClonePath(spec: GitHubRepoSpec, cacheDir: string): string;
@@ -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>;