@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.7.0",
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.7.0",
65
- "@f5-sales-demo/pi-agent-core": "20.7.0",
66
- "@f5-sales-demo/pi-ai": "20.7.0",
67
- "@f5-sales-demo/pi-natives": "20.7.0",
68
- "@f5-sales-demo/pi-resource-management": "20.7.0",
69
- "@f5-sales-demo/pi-tui": "20.7.0",
70
- "@f5-sales-demo/pi-utils": "20.7.0",
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.7.0",
21
- "commit": "1e634cfb3d15f8774c756a1eed5b1ef81c24c35a",
22
- "shortCommit": "1e634cf",
20
+ "version": "20.8.0",
21
+ "commit": "27be1c998051e2ab0ffced6120383b502b2b77c6",
22
+ "shortCommit": "27be1c9",
23
23
  "branch": "main",
24
- "tag": "v20.7.0",
25
- "commitDate": "2026-08-07T13:11:40Z",
26
- "buildDate": "2026-08-07T13:38:50.245Z",
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/1e634cfb3d15f8774c756a1eed5b1ef81c24c35a",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.7.0"
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
- // Extract skill name from host
58
- const skillName = url.rawHost || url.hostname;
59
- if (!skillName) {
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
- // Find the skill
64
- const skill = skills.find(s => s.name === skillName);
65
- if (!skill) {
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
- throw new Error(`Unknown skill: ${skillName}\nAvailable: ${availableStr}`);
70
+ const requested = rawHost + (rawPathname ? rawPathname : "");
71
+ throw new Error(`Unknown skill: ${requested}\nAvailable: ${availableStr}`);
69
72
  }
70
73
 
71
- // Determine the file to read
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 (hasRelativePath) {
77
- // Read relative path within skill's baseDir
78
- const relativePath = decodeURIComponent(urlPath.slice(1)); // Remove leading /
79
- validateRelativePath(relativePath);
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 the resolved path is still within baseDir
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 rawSkillSegment = parsed[1];
58
- if (!rawSkillSegment) {
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
- rawSkillSegment = decodeURIComponent(rawSkillSegment);
64
+ rawHost = decodeURIComponent(rawHost);
64
65
  } catch {
65
66
  // Leave as-is if decoding fails
66
67
  }
67
68
 
68
- // Resolve skill name by longest-prefix match against registered skills.
69
- // This handles namespaced skills ("plugin:skill") where the URI may also
70
- // carry a colon-delimited suffix (e.g., ":1-5" line range).
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
- throw new ToolError(`Unknown skill: ${rawSkillSegment}. Available: ${availableStr}`);
74
+ const requested = rawHost;
75
+ throw new ToolError(`Unknown skill: ${requested}. Available: ${availableStr}`);
76
76
  }
77
77
 
78
- // Combine any colon suffix (line range like ":1-5") with the path segment
79
- const rawPath = (parsed[2] ?? "") + (suffix ? `/${suffix}` : "");
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 relativePath: string;
83
+ let decodedRelative: string;
87
84
  try {
88
- relativePath = decodeURIComponent(rawPath.slice(1));
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(relativePath);
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, relativePath);
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;