@jmylchreest/aide-plugin 0.0.54 → 0.0.56
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/bin/aide-wrapper.ts +360 -0
- package/package.json +6 -3
- package/src/cli/mcp.ts +17 -22
- package/src/core/aide-client.ts +26 -16
- package/src/core/context-guard.ts +101 -1
- package/src/core/read-tracking.ts +151 -0
- package/src/core/session-init.ts +2 -2
- package/src/core/session-summary-logic.ts +73 -58
- package/src/core/skill-matcher.ts +5 -12
- package/src/core/tool-tracking.ts +3 -2
- package/src/lib/hud.ts +85 -86
- package/src/lib/logger.ts +38 -9
- package/src/opencode/hooks.ts +43 -13
- package/src/opencode/index.ts +3 -2
- package/bin/aide-wrapper.sh +0 -159
- package/src/lib/skills-registry.ts +0 -362
package/src/opencode/hooks.ts
CHANGED
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
* Stop (blocking) → session.idle re-prompts via session.prompt() for persistence
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { execFileSync
|
|
28
|
+
import { execFileSync } from "child_process";
|
|
29
|
+
import which from "which";
|
|
29
30
|
import { join } from "path";
|
|
30
31
|
import { findAideBinary } from "../core/aide-client.js";
|
|
31
32
|
import {
|
|
@@ -48,6 +49,8 @@ import { trackToolUse, updateToolStats } from "../core/tool-tracking.js";
|
|
|
48
49
|
import { evaluateToolUse, isToolDenied } from "../core/tool-enforcement.js";
|
|
49
50
|
import { checkPersistence, getActiveMode } from "../core/persistence-logic.js";
|
|
50
51
|
import { checkWriteGuard } from "../core/write-guard.js";
|
|
52
|
+
import { checkSmartReadHint } from "../core/context-guard.js";
|
|
53
|
+
import { recordFileRead } from "../core/read-tracking.js";
|
|
51
54
|
import {
|
|
52
55
|
checkComments,
|
|
53
56
|
getCheckableFilePath,
|
|
@@ -226,18 +229,9 @@ function createConfigHandler(
|
|
|
226
229
|
}
|
|
227
230
|
// Skip skills requiring binaries not on PATH
|
|
228
231
|
if (skill.requires_binary && skill.requires_binary.length > 0) {
|
|
229
|
-
const allPresent = skill.requires_binary.every(
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
process.platform === "win32"
|
|
233
|
-
? `where ${bin}`
|
|
234
|
-
: `command -v ${bin}`;
|
|
235
|
-
execSync(cmd, { stdio: "ignore", timeout: 2000 });
|
|
236
|
-
return true;
|
|
237
|
-
} catch {
|
|
238
|
-
return false;
|
|
239
|
-
}
|
|
240
|
-
});
|
|
232
|
+
const allPresent = skill.requires_binary.every(
|
|
233
|
+
(bin) => which.sync(bin, { nothrow: true }) !== null,
|
|
234
|
+
);
|
|
241
235
|
if (!allPresent) continue;
|
|
242
236
|
}
|
|
243
237
|
const commandName = `aide:${skill.name}`;
|
|
@@ -730,6 +724,21 @@ function createToolBeforeHandler(
|
|
|
730
724
|
debug(SOURCE, `Tool enforcement check failed (non-fatal): ${err}`);
|
|
731
725
|
}
|
|
732
726
|
|
|
727
|
+
// Smart read hint: suggest code index for re-reads of unchanged files
|
|
728
|
+
try {
|
|
729
|
+
const hintResult = checkSmartReadHint(
|
|
730
|
+
input.tool,
|
|
731
|
+
(_output.args || {}) as Record<string, unknown>,
|
|
732
|
+
state.cwd,
|
|
733
|
+
state.binary,
|
|
734
|
+
);
|
|
735
|
+
if (hintResult.shouldHint && hintResult.hint) {
|
|
736
|
+
debug(SOURCE, `Smart read hint for ${input.tool}: ${hintResult.hint}`);
|
|
737
|
+
}
|
|
738
|
+
} catch (err) {
|
|
739
|
+
debug(SOURCE, `Smart read hint check failed (non-fatal): ${err}`);
|
|
740
|
+
}
|
|
741
|
+
|
|
733
742
|
// Track tool use
|
|
734
743
|
if (!state.binary) return;
|
|
735
744
|
|
|
@@ -773,6 +782,27 @@ function createToolAfterHandler(
|
|
|
773
782
|
debug(SOURCE, `Partial memory write failed (non-fatal): ${err}`);
|
|
774
783
|
}
|
|
775
784
|
|
|
785
|
+
// Record file reads for smart-read-hint feature
|
|
786
|
+
try {
|
|
787
|
+
const toolArgs = (_output.metadata?.args || {}) as Record<
|
|
788
|
+
string,
|
|
789
|
+
unknown
|
|
790
|
+
>;
|
|
791
|
+
if (
|
|
792
|
+
input.tool.toLowerCase() === "read" &&
|
|
793
|
+
toolArgs.file_path &&
|
|
794
|
+
state.binary
|
|
795
|
+
) {
|
|
796
|
+
recordFileRead(
|
|
797
|
+
state.binary,
|
|
798
|
+
state.cwd,
|
|
799
|
+
toolArgs.file_path as string,
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
} catch (err) {
|
|
803
|
+
debug(SOURCE, `Read tracking failed (non-fatal): ${err}`);
|
|
804
|
+
}
|
|
805
|
+
|
|
776
806
|
// Context pruning: dedup/supersede/purge tool outputs
|
|
777
807
|
try {
|
|
778
808
|
const toolArgs = (_output.metadata?.args || {}) as Record<
|
package/src/opencode/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { basename, dirname, join, resolve } from "path";
|
|
|
40
40
|
import { fileURLToPath } from "url";
|
|
41
41
|
import { existsSync, readFileSync, statSync } from "fs";
|
|
42
42
|
import { createHooks } from "./hooks.js";
|
|
43
|
+
import { isDebugEnabled } from "../lib/logger.js";
|
|
43
44
|
import type { Plugin, PluginInput, Hooks } from "./types.js";
|
|
44
45
|
|
|
45
46
|
// Resolve the plugin package root so we can find bundled skills.
|
|
@@ -218,7 +219,7 @@ export const AidePlugin: Plugin = async (ctx: PluginInput): Promise<Hooks> => {
|
|
|
218
219
|
}
|
|
219
220
|
|
|
220
221
|
// Also log to stderr when debugging
|
|
221
|
-
if (
|
|
222
|
+
if (isDebugEnabled()) console.error(rawLog);
|
|
222
223
|
|
|
223
224
|
const resolved = resolveProjectRoot(ctx);
|
|
224
225
|
|
|
@@ -231,7 +232,7 @@ export const AidePlugin: Plugin = async (ctx: PluginInput): Promise<Hooks> => {
|
|
|
231
232
|
} catch {
|
|
232
233
|
// non-fatal
|
|
233
234
|
}
|
|
234
|
-
if (
|
|
235
|
+
if (isDebugEnabled()) console.error(resolvedLog);
|
|
235
236
|
|
|
236
237
|
return createHooks(resolved.root, ctx.worktree, ctx.client, pluginRoot, {
|
|
237
238
|
skipInit: !resolved.hasProjectRoot,
|
package/bin/aide-wrapper.sh
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# aide-wrapper.sh - Ensures aide binary exists before executing
|
|
3
|
-
#
|
|
4
|
-
# This wrapper is called by an assistant's MCP server configuration.
|
|
5
|
-
# It finds the aide binary, downloads it if missing, then delegates to it.
|
|
6
|
-
#
|
|
7
|
-
# Plugin root resolution order:
|
|
8
|
-
# 1. AIDE_PLUGIN_ROOT (canonical, platform-agnostic)
|
|
9
|
-
# 2. CLAUDE_PLUGIN_ROOT (set by Claude Code)
|
|
10
|
-
# 3. SCRIPT_DIR/.. (fallback: infer from wrapper location)
|
|
11
|
-
#
|
|
12
|
-
# Lives at: <plugin-root>/bin/aide-wrapper.sh
|
|
13
|
-
# Binary at: <plugin-root>/bin/aide
|
|
14
|
-
#
|
|
15
|
-
# Logs written to: .aide/_logs/wrapper.log
|
|
16
|
-
|
|
17
|
-
set -eo pipefail
|
|
18
|
-
|
|
19
|
-
# Resolve symlinks so that invoking via node_modules/.bin/aide-wrapper
|
|
20
|
-
# (which is a symlink to the real package) gives us the real package dir.
|
|
21
|
-
REAL_SCRIPT="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0")"
|
|
22
|
-
SCRIPT_DIR="$(cd "$(dirname "$REAL_SCRIPT")" && pwd)"
|
|
23
|
-
PLUGIN_ROOT="${AIDE_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}}"
|
|
24
|
-
|
|
25
|
-
# Binary extension
|
|
26
|
-
EXT=""
|
|
27
|
-
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "win32" ]]; then
|
|
28
|
-
EXT=".exe"
|
|
29
|
-
fi
|
|
30
|
-
|
|
31
|
-
BINARY="$PLUGIN_ROOT/bin/aide${EXT}"
|
|
32
|
-
BIN_DIR="$PLUGIN_ROOT/bin"
|
|
33
|
-
|
|
34
|
-
# Setup logging
|
|
35
|
-
LOG_DIR="$PLUGIN_ROOT/.aide/_logs"
|
|
36
|
-
LOG_FILE="$LOG_DIR/wrapper.log"
|
|
37
|
-
mkdir -p "$LOG_DIR" 2>/dev/null || true
|
|
38
|
-
|
|
39
|
-
log() {
|
|
40
|
-
local timestamp
|
|
41
|
-
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
|
|
42
|
-
local msg="[$timestamp] [aide-wrapper] $*"
|
|
43
|
-
echo "$msg" >&2
|
|
44
|
-
echo "$msg" >> "$LOG_FILE" 2>/dev/null || true
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
PARENT_PID=$PPID
|
|
48
|
-
PARENT_CMD=$(ps -o comm= -p "$PPID" 2>/dev/null || echo "unknown")
|
|
49
|
-
PARENT_ARGS=$(ps -o args= -p "$PPID" 2>/dev/null || echo "unknown")
|
|
50
|
-
log "Starting wrapper (pid=$$, ppid=$PARENT_PID, parent=$PARENT_CMD, parent_args=$PARENT_ARGS, args=$*)"
|
|
51
|
-
log "PLUGIN_ROOT=$PLUGIN_ROOT"
|
|
52
|
-
log "BINARY=$BINARY"
|
|
53
|
-
|
|
54
|
-
# Version comparison: returns 0 (true) if $1 >= $2 (semver base only)
|
|
55
|
-
version_gte() {
|
|
56
|
-
local IFS=.
|
|
57
|
-
local a=($1) b=($2)
|
|
58
|
-
for i in 0 1 2; do
|
|
59
|
-
local va=${a[$i]:-0} vb=${b[$i]:-0}
|
|
60
|
-
if (( va > vb )); then return 0; fi
|
|
61
|
-
if (( va < vb )); then return 1; fi
|
|
62
|
-
done
|
|
63
|
-
return 0
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
# Determine if we need to download
|
|
67
|
-
NEEDS_DOWNLOAD=false
|
|
68
|
-
|
|
69
|
-
if [[ ! -x "$BINARY" ]]; then
|
|
70
|
-
NEEDS_DOWNLOAD=true
|
|
71
|
-
log "Binary not found or not executable"
|
|
72
|
-
else
|
|
73
|
-
# Check if this is a dev build that should be preserved
|
|
74
|
-
BINARY_VERSION=$("$BINARY" version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.+]+)?' | head -1 || true)
|
|
75
|
-
log "Binary version: ${BINARY_VERSION:-unknown}"
|
|
76
|
-
|
|
77
|
-
if [[ -n "$BINARY_VERSION" && "$BINARY_VERSION" == *"-dev."* ]]; then
|
|
78
|
-
# Dev build — check base version against plugin version
|
|
79
|
-
BASE_VERSION="${BINARY_VERSION%%-*}"
|
|
80
|
-
# Read plugin version: try .claude-plugin/plugin.json (dev), fall back to package.json (npm)
|
|
81
|
-
PLUGIN_VERSION=$(grep -oP '"version"\s*:\s*"\K[0-9]+\.[0-9]+\.[0-9]+' "$PLUGIN_ROOT/.claude-plugin/plugin.json" 2>/dev/null \
|
|
82
|
-
|| grep -oP '"version"\s*:\s*"\K[0-9]+\.[0-9]+\.[0-9]+' "$PLUGIN_ROOT/package.json" 2>/dev/null \
|
|
83
|
-
|| true)
|
|
84
|
-
|
|
85
|
-
if [[ -n "$PLUGIN_VERSION" ]] && version_gte "$BASE_VERSION" "$PLUGIN_VERSION"; then
|
|
86
|
-
log "Dev build v$BINARY_VERSION (base $BASE_VERSION >= plugin v$PLUGIN_VERSION), using local build"
|
|
87
|
-
else
|
|
88
|
-
NEEDS_DOWNLOAD=true
|
|
89
|
-
log "Dev build v$BINARY_VERSION is older than plugin v${PLUGIN_VERSION:-unknown}, re-downloading"
|
|
90
|
-
fi
|
|
91
|
-
else
|
|
92
|
-
log "Release binary v${BINARY_VERSION:-unknown}"
|
|
93
|
-
fi
|
|
94
|
-
fi
|
|
95
|
-
|
|
96
|
-
if [[ "$NEEDS_DOWNLOAD" == "true" ]]; then
|
|
97
|
-
LOCKFILE="$BIN_DIR/.aide-download.lock"
|
|
98
|
-
|
|
99
|
-
# Resolve the downloader script — prefer src/ (dev) but fall back to dist/ (npm install)
|
|
100
|
-
if [[ -f "$PLUGIN_ROOT/src/lib/aide-downloader.ts" ]]; then
|
|
101
|
-
DOWNLOADER="$PLUGIN_ROOT/src/lib/aide-downloader.ts"
|
|
102
|
-
elif [[ -f "$PLUGIN_ROOT/dist/lib/aide-downloader.js" ]]; then
|
|
103
|
-
DOWNLOADER="$PLUGIN_ROOT/dist/lib/aide-downloader.js"
|
|
104
|
-
else
|
|
105
|
-
log "ERROR: Cannot find aide-downloader in src/ or dist/ under $PLUGIN_ROOT"
|
|
106
|
-
exit 1
|
|
107
|
-
fi
|
|
108
|
-
|
|
109
|
-
# Remove the stale/outdated binary before entering the lock so that
|
|
110
|
-
# the re-check inside the critical section can distinguish between
|
|
111
|
-
# "no binary yet" and "a concurrent process just downloaded the right one".
|
|
112
|
-
if [[ -f "$BINARY" ]]; then
|
|
113
|
-
log "Removing outdated binary before download"
|
|
114
|
-
rm -f "$BINARY" 2>/dev/null || true
|
|
115
|
-
fi
|
|
116
|
-
|
|
117
|
-
# Use flock to ensure only one process downloads at a time.
|
|
118
|
-
# If another process holds the lock, we wait for it to finish.
|
|
119
|
-
(
|
|
120
|
-
flock -w 60 9 || { log "ERROR: Timed out waiting for download lock"; exit 1; }
|
|
121
|
-
|
|
122
|
-
# Re-check after acquiring lock — another process may have finished the download
|
|
123
|
-
if [[ -x "$BINARY" ]]; then
|
|
124
|
-
log "Binary appeared while waiting for lock (downloaded by another process)"
|
|
125
|
-
else
|
|
126
|
-
log "Downloading binary..."
|
|
127
|
-
log "Using downloader: $DOWNLOADER"
|
|
128
|
-
|
|
129
|
-
# Use bun/tsx for .ts files (dev), node for .js files (npm install)
|
|
130
|
-
if [[ "$DOWNLOADER" == *.ts ]]; then
|
|
131
|
-
if command -v bun &>/dev/null; then
|
|
132
|
-
RUNNER="bun"
|
|
133
|
-
elif command -v tsx &>/dev/null; then
|
|
134
|
-
RUNNER="tsx"
|
|
135
|
-
else
|
|
136
|
-
RUNNER="npx --yes tsx"
|
|
137
|
-
fi
|
|
138
|
-
else
|
|
139
|
-
RUNNER="node"
|
|
140
|
-
fi
|
|
141
|
-
|
|
142
|
-
if ! $RUNNER "$DOWNLOADER" --dest "$BIN_DIR" 2>&1 | tee -a "$LOG_FILE" >&2; then
|
|
143
|
-
log "ERROR: Downloader failed"
|
|
144
|
-
exit 1
|
|
145
|
-
fi
|
|
146
|
-
|
|
147
|
-
if [[ ! -x "$BINARY" ]]; then
|
|
148
|
-
log "ERROR: Binary not found after download"
|
|
149
|
-
exit 1
|
|
150
|
-
fi
|
|
151
|
-
fi
|
|
152
|
-
) 9>"$LOCKFILE"
|
|
153
|
-
|
|
154
|
-
rm -f "$LOCKFILE"
|
|
155
|
-
log "Binary ready at $BINARY"
|
|
156
|
-
fi
|
|
157
|
-
|
|
158
|
-
log "Executing: $BINARY $*"
|
|
159
|
-
exec "$BINARY" "$@"
|
|
@@ -1,362 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Skills Registry (skills.sh integration)
|
|
3
|
-
*
|
|
4
|
-
* STATUS: UTILITY LIBRARY - Not yet integrated into hooks
|
|
5
|
-
*
|
|
6
|
-
* This library provides programmatic management of skills from the skills.sh
|
|
7
|
-
* marketplace. Skills are markdown files with YAML frontmatter that define
|
|
8
|
-
* triggers and behaviors for the skill-injector hook.
|
|
9
|
-
*
|
|
10
|
-
* Currently, skill discovery and loading is done directly in skill-injector.ts.
|
|
11
|
-
* This registry library is intended for future use cases:
|
|
12
|
-
*
|
|
13
|
-
* Future integration:
|
|
14
|
-
* - CLI commands for `aide skill install/uninstall/update`
|
|
15
|
-
* - Automatic skill updates on session start
|
|
16
|
-
* - Skill marketplace browsing and search
|
|
17
|
-
*
|
|
18
|
-
* The skill-injector hook currently handles skill discovery inline because:
|
|
19
|
-
* 1. It only needs to read local skill files, not manage them
|
|
20
|
-
* 2. It needs to be fast (runs on every user prompt)
|
|
21
|
-
* 3. Registry features (install, update) are not needed at runtime
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { execFileSync } from "child_process";
|
|
25
|
-
import {
|
|
26
|
-
existsSync,
|
|
27
|
-
readFileSync,
|
|
28
|
-
writeFileSync,
|
|
29
|
-
mkdirSync,
|
|
30
|
-
readdirSync,
|
|
31
|
-
copyFileSync,
|
|
32
|
-
unlinkSync,
|
|
33
|
-
} from "fs";
|
|
34
|
-
import { join, basename, resolve } from "path";
|
|
35
|
-
import { homedir } from "os";
|
|
36
|
-
|
|
37
|
-
export interface SkillMetadata {
|
|
38
|
-
name: string;
|
|
39
|
-
version: string;
|
|
40
|
-
source: string;
|
|
41
|
-
installedAt: string;
|
|
42
|
-
updatedAt?: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface SkillsRegistry {
|
|
46
|
-
installed: SkillMetadata[];
|
|
47
|
-
autoUpdate: boolean;
|
|
48
|
-
syncInterval: string;
|
|
49
|
-
lastSync?: string;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const REGISTRY_FILE = ".aide/skills/registry.json";
|
|
53
|
-
const SKILLS_DIR = ".aide/skills";
|
|
54
|
-
const GLOBAL_SKILLS_DIR = join(homedir(), ".aide", "skills");
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Validate and sanitize a URL for safe fetching
|
|
58
|
-
* Prevents command injection by ensuring only valid http/https URLs
|
|
59
|
-
*/
|
|
60
|
-
function validateUrl(url: string): string | null {
|
|
61
|
-
try {
|
|
62
|
-
const parsed = new URL(url);
|
|
63
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
// Return the properly parsed URL (sanitized)
|
|
67
|
-
return parsed.toString();
|
|
68
|
-
} catch {
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Safely fetch content from a URL using curl
|
|
75
|
-
* Uses execFileSync with argument array to prevent command injection
|
|
76
|
-
*/
|
|
77
|
-
function safeFetch(url: string): string | null {
|
|
78
|
-
const validUrl = validateUrl(url);
|
|
79
|
-
if (!validUrl) {
|
|
80
|
-
console.error(`Invalid URL: ${url}`);
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
try {
|
|
84
|
-
return execFileSync("curl", ["-sL", validUrl], {
|
|
85
|
-
encoding: "utf-8",
|
|
86
|
-
timeout: 30000,
|
|
87
|
-
});
|
|
88
|
-
} catch (error) {
|
|
89
|
-
console.error(`Failed to fetch ${validUrl}: ${error}`);
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Load skills registry
|
|
96
|
-
*/
|
|
97
|
-
export function loadRegistry(cwd: string): SkillsRegistry {
|
|
98
|
-
const registryPath = join(cwd, REGISTRY_FILE);
|
|
99
|
-
if (existsSync(registryPath)) {
|
|
100
|
-
try {
|
|
101
|
-
return JSON.parse(readFileSync(registryPath, "utf-8"));
|
|
102
|
-
} catch {
|
|
103
|
-
// Return default
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return {
|
|
107
|
-
installed: [],
|
|
108
|
-
autoUpdate: true,
|
|
109
|
-
syncInterval: "24h",
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Save skills registry
|
|
115
|
-
*/
|
|
116
|
-
export function saveRegistry(cwd: string, registry: SkillsRegistry): void {
|
|
117
|
-
const skillsDir = join(cwd, SKILLS_DIR);
|
|
118
|
-
if (!existsSync(skillsDir)) {
|
|
119
|
-
mkdirSync(skillsDir, { recursive: true });
|
|
120
|
-
}
|
|
121
|
-
writeFileSync(join(cwd, REGISTRY_FILE), JSON.stringify(registry, null, 2));
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Sanitize a skill name to prevent path traversal.
|
|
126
|
-
* Strips path separators and rejects names that would escape the target directory.
|
|
127
|
-
*/
|
|
128
|
-
function sanitizeSkillName(name: string): string {
|
|
129
|
-
// Take only the basename to strip any directory components
|
|
130
|
-
const safe = basename(name).replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
131
|
-
if (!safe || safe === "." || safe === "..") {
|
|
132
|
-
throw new Error(`Invalid skill name: ${name}`);
|
|
133
|
-
}
|
|
134
|
-
return safe;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Validate that a resolved path stays within the expected directory.
|
|
139
|
-
*/
|
|
140
|
-
function assertWithinDir(filePath: string, dir: string): void {
|
|
141
|
-
const resolved = resolve(filePath);
|
|
142
|
-
const resolvedDir = resolve(dir);
|
|
143
|
-
if (!resolved.startsWith(resolvedDir + "/") && resolved !== resolvedDir) {
|
|
144
|
-
throw new Error(`Path traversal detected: ${filePath} escapes ${dir}`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Install a skill from skills.sh or a URL
|
|
150
|
-
*
|
|
151
|
-
* Formats:
|
|
152
|
-
* - skills.sh/<author>/<skill>
|
|
153
|
-
* - https://github.com/<owner>/<repo>/blob/main/<path>.md
|
|
154
|
-
* - Local file path
|
|
155
|
-
*/
|
|
156
|
-
export async function installSkill(
|
|
157
|
-
cwd: string,
|
|
158
|
-
source: string,
|
|
159
|
-
options: { global?: boolean } = {},
|
|
160
|
-
): Promise<SkillMetadata | null> {
|
|
161
|
-
const targetDir = options.global ? GLOBAL_SKILLS_DIR : join(cwd, SKILLS_DIR);
|
|
162
|
-
|
|
163
|
-
// Ensure target directory exists
|
|
164
|
-
if (!existsSync(targetDir)) {
|
|
165
|
-
mkdirSync(targetDir, { recursive: true });
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
let content: string;
|
|
169
|
-
let name: string;
|
|
170
|
-
let version = "1.0.0";
|
|
171
|
-
|
|
172
|
-
// Handle different source formats
|
|
173
|
-
if (
|
|
174
|
-
source.startsWith("skills.sh/") ||
|
|
175
|
-
source.startsWith("https://skills.sh/")
|
|
176
|
-
) {
|
|
177
|
-
// skills.sh marketplace format: skills.sh/author/skill
|
|
178
|
-
const skillPath = source
|
|
179
|
-
.replace("skills.sh/", "")
|
|
180
|
-
.replace("https://skills.sh/", "");
|
|
181
|
-
const url = `https://skills.sh/api/skills/${skillPath}/raw`;
|
|
182
|
-
|
|
183
|
-
const fetched = safeFetch(url);
|
|
184
|
-
if (!fetched) {
|
|
185
|
-
console.error(`Failed to fetch skill from skills.sh`);
|
|
186
|
-
return null;
|
|
187
|
-
}
|
|
188
|
-
content = fetched;
|
|
189
|
-
name = skillPath.split("/").pop() || "unknown";
|
|
190
|
-
} else if (source.startsWith("https://github.com/")) {
|
|
191
|
-
// GitHub raw file
|
|
192
|
-
const rawUrl = source
|
|
193
|
-
.replace("github.com", "raw.githubusercontent.com")
|
|
194
|
-
.replace("/blob/", "/");
|
|
195
|
-
|
|
196
|
-
const fetched = safeFetch(rawUrl);
|
|
197
|
-
if (!fetched) {
|
|
198
|
-
console.error(`Failed to fetch skill from GitHub`);
|
|
199
|
-
return null;
|
|
200
|
-
}
|
|
201
|
-
content = fetched;
|
|
202
|
-
name = basename(source, ".md");
|
|
203
|
-
} else if (source.startsWith("https://") || source.startsWith("http://")) {
|
|
204
|
-
// Direct URL
|
|
205
|
-
const fetched = safeFetch(source);
|
|
206
|
-
if (!fetched) {
|
|
207
|
-
console.error(`Failed to fetch skill`);
|
|
208
|
-
return null;
|
|
209
|
-
}
|
|
210
|
-
content = fetched;
|
|
211
|
-
name = basename(source, ".md");
|
|
212
|
-
} else if (existsSync(source)) {
|
|
213
|
-
// Local file
|
|
214
|
-
content = readFileSync(source, "utf-8");
|
|
215
|
-
name = basename(source, ".md");
|
|
216
|
-
} else {
|
|
217
|
-
console.error(`Invalid skill source: ${source}`);
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Parse skill to extract metadata
|
|
222
|
-
const meta = parseSkillFrontmatter(content);
|
|
223
|
-
if (meta) {
|
|
224
|
-
name = meta.name || name;
|
|
225
|
-
version = meta.version || version;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Sanitize name and write skill file
|
|
229
|
-
name = sanitizeSkillName(name);
|
|
230
|
-
const skillPath = join(targetDir, `${name}.md`);
|
|
231
|
-
assertWithinDir(skillPath, targetDir);
|
|
232
|
-
writeFileSync(skillPath, content);
|
|
233
|
-
|
|
234
|
-
// Update registry
|
|
235
|
-
const registry = loadRegistry(cwd);
|
|
236
|
-
const existing = registry.installed.findIndex((s) => s.name === name);
|
|
237
|
-
|
|
238
|
-
const metadata: SkillMetadata = {
|
|
239
|
-
name,
|
|
240
|
-
version,
|
|
241
|
-
source,
|
|
242
|
-
installedAt: new Date().toISOString(),
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
if (existing >= 0) {
|
|
246
|
-
metadata.installedAt = registry.installed[existing].installedAt;
|
|
247
|
-
metadata.updatedAt = new Date().toISOString();
|
|
248
|
-
registry.installed[existing] = metadata;
|
|
249
|
-
} else {
|
|
250
|
-
registry.installed.push(metadata);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
saveRegistry(cwd, registry);
|
|
254
|
-
|
|
255
|
-
console.log(`Installed skill: ${name} (${version})`);
|
|
256
|
-
return metadata;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Uninstall a skill
|
|
261
|
-
*/
|
|
262
|
-
export function uninstallSkill(cwd: string, name: string): boolean {
|
|
263
|
-
const registry = loadRegistry(cwd);
|
|
264
|
-
const index = registry.installed.findIndex((s) => s.name === name);
|
|
265
|
-
|
|
266
|
-
if (index < 0) {
|
|
267
|
-
console.error(`Skill not installed: ${name}`);
|
|
268
|
-
return false;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Sanitize name and remove file
|
|
272
|
-
const safeName = sanitizeSkillName(name);
|
|
273
|
-
const skillPath = join(cwd, SKILLS_DIR, `${safeName}.md`);
|
|
274
|
-
assertWithinDir(skillPath, join(cwd, SKILLS_DIR));
|
|
275
|
-
if (existsSync(skillPath)) {
|
|
276
|
-
try {
|
|
277
|
-
unlinkSync(skillPath);
|
|
278
|
-
} catch {
|
|
279
|
-
// Ignore
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Update registry
|
|
284
|
-
registry.installed.splice(index, 1);
|
|
285
|
-
saveRegistry(cwd, registry);
|
|
286
|
-
|
|
287
|
-
console.log(`Uninstalled skill: ${name}`);
|
|
288
|
-
return true;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* List installed skills
|
|
293
|
-
*/
|
|
294
|
-
export function listSkills(cwd: string): SkillMetadata[] {
|
|
295
|
-
const registry = loadRegistry(cwd);
|
|
296
|
-
return registry.installed;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Update all skills (if autoUpdate is enabled)
|
|
301
|
-
*/
|
|
302
|
-
export async function updateSkills(cwd: string): Promise<void> {
|
|
303
|
-
const registry = loadRegistry(cwd);
|
|
304
|
-
|
|
305
|
-
if (!registry.autoUpdate) {
|
|
306
|
-
console.log("Auto-update is disabled");
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
console.log("Updating skills...");
|
|
311
|
-
|
|
312
|
-
for (const skill of registry.installed) {
|
|
313
|
-
if (
|
|
314
|
-
skill.source.startsWith("skills.sh/") ||
|
|
315
|
-
skill.source.startsWith("https://")
|
|
316
|
-
) {
|
|
317
|
-
await installSkill(cwd, skill.source);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
registry.lastSync = new Date().toISOString();
|
|
322
|
-
saveRegistry(cwd, registry);
|
|
323
|
-
|
|
324
|
-
console.log("Skills updated");
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Search for skills (placeholder - would query skills.sh API)
|
|
329
|
-
*/
|
|
330
|
-
export async function searchSkills(
|
|
331
|
-
query: string,
|
|
332
|
-
): Promise<Array<{ name: string; description: string; author: string }>> {
|
|
333
|
-
// In a real implementation, this would query the skills.sh API
|
|
334
|
-
console.log(`Searching for skills matching: ${query}`);
|
|
335
|
-
console.log("Note: skills.sh integration requires API access");
|
|
336
|
-
|
|
337
|
-
return [];
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Parse YAML frontmatter from skill content
|
|
342
|
-
*/
|
|
343
|
-
function parseSkillFrontmatter(
|
|
344
|
-
content: string,
|
|
345
|
-
): { name?: string; version?: string; description?: string } | null {
|
|
346
|
-
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
347
|
-
if (!match) return null;
|
|
348
|
-
|
|
349
|
-
const yaml = match[1];
|
|
350
|
-
const result: { name?: string; version?: string; description?: string } = {};
|
|
351
|
-
|
|
352
|
-
const nameMatch = yaml.match(/^name:\s*(.+)$/m);
|
|
353
|
-
if (nameMatch) result.name = nameMatch[1].trim();
|
|
354
|
-
|
|
355
|
-
const versionMatch = yaml.match(/^version:\s*(.+)$/m);
|
|
356
|
-
if (versionMatch) result.version = versionMatch[1].trim();
|
|
357
|
-
|
|
358
|
-
const descMatch = yaml.match(/^description:\s*(.+)$/m);
|
|
359
|
-
if (descMatch) result.description = descMatch[1].trim();
|
|
360
|
-
|
|
361
|
-
return result;
|
|
362
|
-
}
|