@f5-sales-demo/xcsh 20.7.0 → 20.8.0
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "20.
|
|
4
|
+
"version": "20.8.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -61,13 +61,13 @@
|
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
63
63
|
"@mozilla/readability": "^0.6",
|
|
64
|
-
"@f5-sales-demo/xcsh-stats": "20.
|
|
65
|
-
"@f5-sales-demo/pi-agent-core": "20.
|
|
66
|
-
"@f5-sales-demo/pi-ai": "20.
|
|
67
|
-
"@f5-sales-demo/pi-natives": "20.
|
|
68
|
-
"@f5-sales-demo/pi-resource-management": "20.
|
|
69
|
-
"@f5-sales-demo/pi-tui": "20.
|
|
70
|
-
"@f5-sales-demo/pi-utils": "20.
|
|
64
|
+
"@f5-sales-demo/xcsh-stats": "20.8.0",
|
|
65
|
+
"@f5-sales-demo/pi-agent-core": "20.8.0",
|
|
66
|
+
"@f5-sales-demo/pi-ai": "20.8.0",
|
|
67
|
+
"@f5-sales-demo/pi-natives": "20.8.0",
|
|
68
|
+
"@f5-sales-demo/pi-resource-management": "20.8.0",
|
|
69
|
+
"@f5-sales-demo/pi-tui": "20.8.0",
|
|
70
|
+
"@f5-sales-demo/pi-utils": "20.8.0",
|
|
71
71
|
"@sinclair/typebox": "^0.34",
|
|
72
72
|
"@xterm/headless": "^6.0",
|
|
73
73
|
"ajv": "^8.20",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Skill } from "./skills";
|
|
2
|
+
|
|
3
|
+
export interface SkillMatchResult {
|
|
4
|
+
skill: Skill;
|
|
5
|
+
relativePath: string;
|
|
6
|
+
suffix?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Known plugin alias mappings for backwards compatibility (e.g. collapsed/renamed plugins) */
|
|
10
|
+
const PLUGIN_ALIASES: Record<string, string> = {
|
|
11
|
+
"github-ops": "github",
|
|
12
|
+
"f5xc-github-ops": "github",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Match a raw skill path (from URL host + pathname) against registered skills.
|
|
17
|
+
* Handles:
|
|
18
|
+
* - Exact namespaced matches: "github:workflow-lifecycle"
|
|
19
|
+
* - Slash-formatted namespaced matches: "github/workflow-lifecycle"
|
|
20
|
+
* - Redundant SKILL.md paths: "github/workflow-lifecycle/SKILL.md" -> relativePath = ""
|
|
21
|
+
* - Plugin aliases: "github-ops:workflow-lifecycle" -> "github:workflow-lifecycle"
|
|
22
|
+
* - Colon-delimited line range suffixes: "github:workflow-lifecycle:1-10"
|
|
23
|
+
* - Relative files: "github:workflow-lifecycle/scripts/run.sh"
|
|
24
|
+
*/
|
|
25
|
+
export function resolveSkillFromPath(
|
|
26
|
+
rawHost: string,
|
|
27
|
+
rawPathname: string,
|
|
28
|
+
skills: readonly Skill[],
|
|
29
|
+
): SkillMatchResult | null {
|
|
30
|
+
const rawCombined = rawHost + (rawPathname ? rawPathname : "");
|
|
31
|
+
if (!rawCombined) return null;
|
|
32
|
+
|
|
33
|
+
// Build key variants for each registered skill
|
|
34
|
+
// Sorted by key length descending so longer skill names match first
|
|
35
|
+
interface KeyEntry {
|
|
36
|
+
key: string;
|
|
37
|
+
skill: Skill;
|
|
38
|
+
}
|
|
39
|
+
const keyEntries: KeyEntry[] = [];
|
|
40
|
+
|
|
41
|
+
for (const skill of skills) {
|
|
42
|
+
const canonical = skill.name;
|
|
43
|
+
keyEntries.push({ key: canonical, skill });
|
|
44
|
+
|
|
45
|
+
// Slash version: "github:workflow-lifecycle" -> "github/workflow-lifecycle"
|
|
46
|
+
if (canonical.includes(":")) {
|
|
47
|
+
keyEntries.push({ key: canonical.replace(":", "/"), skill });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Alias versions (e.g. github-ops -> github)
|
|
51
|
+
for (const [alias, target] of Object.entries(PLUGIN_ALIASES)) {
|
|
52
|
+
if (canonical.startsWith(`${target}:`)) {
|
|
53
|
+
const aliasColon = canonical.replace(`${target}:`, `${alias}:`);
|
|
54
|
+
const aliasSlash = canonical.replace(`${target}:`, `${alias}/`);
|
|
55
|
+
keyEntries.push({ key: aliasColon, skill });
|
|
56
|
+
keyEntries.push({ key: aliasSlash, skill });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Sort key entries by length descending
|
|
62
|
+
keyEntries.sort((a, b) => b.key.length - a.key.length);
|
|
63
|
+
|
|
64
|
+
for (const entry of keyEntries) {
|
|
65
|
+
if (rawCombined === entry.key) {
|
|
66
|
+
return { skill: entry.skill, relativePath: "" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (rawCombined.startsWith(`${entry.key}/`) || rawCombined.startsWith(`${entry.key}:`)) {
|
|
70
|
+
const sep = rawCombined[entry.key.length];
|
|
71
|
+
let remainder = rawCombined.slice(entry.key.length + 1);
|
|
72
|
+
|
|
73
|
+
if (sep === ":") {
|
|
74
|
+
// Line range or colon suffix
|
|
75
|
+
return { skill: entry.skill, relativePath: "", suffix: remainder };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Sep is '/'
|
|
79
|
+
if (remainder === "SKILL.md" || remainder === "SKILL.MD") {
|
|
80
|
+
remainder = "";
|
|
81
|
+
}
|
|
82
|
+
return { skill: entry.skill, relativePath: remainder };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "20.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "20.8.0",
|
|
21
|
+
"commit": "27be1c998051e2ab0ffced6120383b502b2b77c6",
|
|
22
|
+
"shortCommit": "27be1c9",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v20.
|
|
25
|
-
"commitDate": "2026-08-
|
|
26
|
-
"buildDate": "2026-08-
|
|
24
|
+
"tag": "v20.8.0",
|
|
25
|
+
"commitDate": "2026-08-08T03:42:08Z",
|
|
26
|
+
"buildDate": "2026-08-08T04:06:22.569Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5-sales-demo/xcsh",
|
|
30
30
|
"repoSlug": "f5-sales-demo/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/27be1c998051e2ab0ffced6120383b502b2b77c6",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.8.0"
|
|
33
33
|
};
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - skill://<name>/<path> - Reads relative path within skill's baseDir
|
|
9
9
|
*/
|
|
10
10
|
import * as path from "node:path";
|
|
11
|
+
import { resolveSkillFromPath } from "../extensibility/skill-resolution";
|
|
11
12
|
import type { Skill } from "../extensibility/skills";
|
|
12
13
|
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
|
|
13
14
|
|
|
@@ -54,39 +55,37 @@ export class SkillProtocolHandler implements ProtocolHandler {
|
|
|
54
55
|
async resolve(url: InternalUrl): Promise<InternalResource> {
|
|
55
56
|
const skills = this.options.getSkills();
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
58
|
+
const rawHost = url.rawHost || url.hostname || "";
|
|
59
|
+
const rawPathname = url.rawPathname || url.pathname || "";
|
|
60
|
+
|
|
61
|
+
if (!rawHost && !rawPathname) {
|
|
60
62
|
throw new Error("skill:// URL requires a skill name: skill://<name>");
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
//
|
|
64
|
-
const
|
|
65
|
-
if (!
|
|
65
|
+
// Resolve skill using resilient matcher (handles exact, colon line-ranges, slash notation, aliases)
|
|
66
|
+
const match = resolveSkillFromPath(rawHost, rawPathname, skills);
|
|
67
|
+
if (!match) {
|
|
66
68
|
const available = skills.map(s => s.name);
|
|
67
69
|
const availableStr = available.length > 0 ? available.join(", ") : "none";
|
|
68
|
-
|
|
70
|
+
const requested = rawHost + (rawPathname ? rawPathname : "");
|
|
71
|
+
throw new Error(`Unknown skill: ${requested}\nAvailable: ${availableStr}`);
|
|
69
72
|
}
|
|
70
73
|
|
|
71
|
-
|
|
74
|
+
const { skill, relativePath } = match;
|
|
72
75
|
let targetPath: string;
|
|
73
|
-
const urlPath = url.pathname;
|
|
74
|
-
const hasRelativePath = urlPath && urlPath !== "/" && urlPath !== "";
|
|
75
76
|
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
targetPath = path.join(skill.baseDir, relativePath);
|
|
77
|
+
if (relativePath) {
|
|
78
|
+
const decoded = decodeURIComponent(relativePath);
|
|
79
|
+
validateRelativePath(decoded);
|
|
80
|
+
targetPath = path.join(skill.baseDir, decoded);
|
|
81
81
|
|
|
82
|
-
// Verify
|
|
82
|
+
// Verify resolved path stays within skill baseDir
|
|
83
83
|
const resolvedPath = path.resolve(targetPath);
|
|
84
84
|
const resolvedBaseDir = path.resolve(skill.baseDir);
|
|
85
85
|
if (!resolvedPath.startsWith(resolvedBaseDir + path.sep) && resolvedPath !== resolvedBaseDir) {
|
|
86
86
|
throw new Error("Path traversal is not allowed");
|
|
87
87
|
}
|
|
88
88
|
} else {
|
|
89
|
-
// Read SKILL.md
|
|
90
89
|
targetPath = skill.filePath;
|
|
91
90
|
}
|
|
92
91
|
|
|
@@ -43,6 +43,8 @@ export interface InternalUrlExpansionOptions {
|
|
|
43
43
|
sessionOwnedRoots?: () => readonly string[];
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
import { resolveSkillFromPath } from "../extensibility/skill-resolution";
|
|
47
|
+
|
|
46
48
|
/**
|
|
47
49
|
* Resolve a single skill:// URL to its absolute filesystem path.
|
|
48
50
|
* Does NOT read file content or verify existence.
|
|
@@ -54,49 +56,45 @@ export function resolveSkillUrlToPath(url: string, skills: readonly Skill[]): st
|
|
|
54
56
|
throw new ToolError(`Invalid skill:// URL: ${url}`);
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
let
|
|
58
|
-
if (!
|
|
59
|
+
let rawHost = parsed[1];
|
|
60
|
+
if (!rawHost) {
|
|
59
61
|
throw new ToolError(`skill:// URL requires a skill name: ${url}`);
|
|
60
62
|
}
|
|
61
|
-
// Decode percent-encoded colons (%3A) used for namespaced skill names
|
|
62
63
|
try {
|
|
63
|
-
|
|
64
|
+
rawHost = decodeURIComponent(rawHost);
|
|
64
65
|
} catch {
|
|
65
66
|
// Leave as-is if decoding fails
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const { skill, suffix } = matchSkillName(rawSkillSegment, skills);
|
|
72
|
-
if (!skill) {
|
|
69
|
+
const rawPathname = parsed[2] ?? "";
|
|
70
|
+
const match = resolveSkillFromPath(rawHost, rawPathname, skills);
|
|
71
|
+
if (!match) {
|
|
73
72
|
const available = skills.map(s => s.name);
|
|
74
73
|
const availableStr = available.length > 0 ? available.join(", ") : "none";
|
|
75
|
-
|
|
74
|
+
const requested = rawHost;
|
|
75
|
+
throw new ToolError(`Unknown skill: ${requested}. Available: ${availableStr}`);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const hasRelativePath = rawPath !== "" && rawPath !== "/";
|
|
81
|
-
|
|
82
|
-
if (!hasRelativePath) {
|
|
78
|
+
const { skill, relativePath } = match;
|
|
79
|
+
if (!relativePath) {
|
|
83
80
|
return path.resolve(skill.filePath);
|
|
84
81
|
}
|
|
85
82
|
|
|
86
|
-
let
|
|
83
|
+
let decodedRelative: string;
|
|
87
84
|
try {
|
|
88
|
-
|
|
85
|
+
decodedRelative = decodeURIComponent(relativePath);
|
|
89
86
|
} catch {
|
|
90
87
|
throw new ToolError(`Invalid skill:// URL path encoding: ${url}`);
|
|
91
88
|
}
|
|
89
|
+
|
|
92
90
|
try {
|
|
93
|
-
validateRelativePath(
|
|
91
|
+
validateRelativePath(decodedRelative);
|
|
94
92
|
} catch (err) {
|
|
95
93
|
const message = err instanceof Error ? err.message : String(err);
|
|
96
94
|
throw new ToolError(message);
|
|
97
95
|
}
|
|
98
96
|
|
|
99
|
-
const targetPath = path.join(skill.baseDir,
|
|
97
|
+
const targetPath = path.join(skill.baseDir, decodedRelative);
|
|
100
98
|
const resolvedPath = path.resolve(targetPath);
|
|
101
99
|
const resolvedBaseDir = path.resolve(skill.baseDir);
|
|
102
100
|
if (!resolvedPath.startsWith(resolvedBaseDir + path.sep) && resolvedPath !== resolvedBaseDir) {
|
|
@@ -106,39 +104,6 @@ export function resolveSkillUrlToPath(url: string, skills: readonly Skill[]): st
|
|
|
106
104
|
return resolvedPath;
|
|
107
105
|
}
|
|
108
106
|
|
|
109
|
-
/**
|
|
110
|
-
* Match a raw skill segment against registered skills using longest-prefix match.
|
|
111
|
-
* Handles colons in both skill names (namespacing) and suffixes (line ranges).
|
|
112
|
-
*
|
|
113
|
-
* For "superpowers:brainstorming:1-5" with skill "superpowers:brainstorming":
|
|
114
|
-
* -> skill = superpowers:brainstorming, suffix = "1-5"
|
|
115
|
-
* For "brainstorming" with skill "brainstorming":
|
|
116
|
-
* -> skill = brainstorming, suffix = undefined
|
|
117
|
-
*/
|
|
118
|
-
function matchSkillName(
|
|
119
|
-
rawSegment: string,
|
|
120
|
-
skills: readonly Skill[],
|
|
121
|
-
): { skill: Skill | undefined; suffix: string | undefined } {
|
|
122
|
-
// Exact match first (most common case)
|
|
123
|
-
const exact = skills.find(s => s.name === rawSegment);
|
|
124
|
-
if (exact) return { skill: exact, suffix: undefined };
|
|
125
|
-
|
|
126
|
-
// Try stripping colon-delimited suffixes from the right
|
|
127
|
-
let candidate = rawSegment;
|
|
128
|
-
while (true) {
|
|
129
|
-
const lastColon = candidate.lastIndexOf(":");
|
|
130
|
-
if (lastColon <= 0) break;
|
|
131
|
-
candidate = candidate.slice(0, lastColon);
|
|
132
|
-
const match = skills.find(s => s.name === candidate);
|
|
133
|
-
if (match) {
|
|
134
|
-
const suffix = rawSegment.slice(lastColon + 1);
|
|
135
|
-
return { skill: match, suffix };
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return { skill: undefined, suffix: undefined };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
107
|
function extractScheme(url: string): SupportedInternalScheme | undefined {
|
|
143
108
|
const match = /^([a-z][a-z0-9+.-]*):\/\//i.exec(url);
|
|
144
109
|
if (!match) return undefined;
|