@f5-sales-demo/xcsh 19.75.0 → 19.76.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": "19.75.0",
4
+ "version": "19.76.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",
@@ -56,13 +56,13 @@
56
56
  "dependencies": {
57
57
  "@agentclientprotocol/sdk": "0.16.1",
58
58
  "@mozilla/readability": "^0.6",
59
- "@f5-sales-demo/xcsh-stats": "19.75.0",
60
- "@f5-sales-demo/pi-agent-core": "19.75.0",
61
- "@f5-sales-demo/pi-ai": "19.75.0",
62
- "@f5-sales-demo/pi-natives": "19.75.0",
63
- "@f5-sales-demo/pi-resource-management": "19.75.0",
64
- "@f5-sales-demo/pi-tui": "19.75.0",
65
- "@f5-sales-demo/pi-utils": "19.75.0",
59
+ "@f5-sales-demo/xcsh-stats": "19.76.0",
60
+ "@f5-sales-demo/pi-agent-core": "19.76.0",
61
+ "@f5-sales-demo/pi-ai": "19.76.0",
62
+ "@f5-sales-demo/pi-natives": "19.76.0",
63
+ "@f5-sales-demo/pi-resource-management": "19.76.0",
64
+ "@f5-sales-demo/pi-tui": "19.76.0",
65
+ "@f5-sales-demo/pi-utils": "19.76.0",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -856,6 +856,67 @@ export async function listXcshPluginRoots(
856
856
  return result;
857
857
  }
858
858
 
859
+ export interface XcshPluginSummary {
860
+ /** Registry id (root.plugin) — the key the `xcsh://plugin/<id>` resolver matches on. */
861
+ id: string;
862
+ /** Display name from the plugin's own manifest (falls back to the registry id). */
863
+ name: string;
864
+ description: string;
865
+ }
866
+
867
+ /**
868
+ * Sanitize a manifest/package description for single-line rendering in the system prompt.
869
+ * Collapses whitespace runs to single spaces, trims, and caps length (appending "…" if truncated).
870
+ * Defense-in-depth: the value is rendered with `noEscape:true`, so a newline would break the
871
+ * single-line bullet and an oversized description would bloat the prompt.
872
+ */
873
+ function sanitizePluginDescription(description: unknown): string {
874
+ if (typeof description !== "string") return "";
875
+ const collapsed = description.replace(/\s+/g, " ").trim();
876
+ return collapsed.length > 300 ? `${collapsed.slice(0, 300)}…` : collapsed;
877
+ }
878
+
879
+ /** Read a plugin's human name+description from its own manifest. Fail-safe: never throws. */
880
+ export async function readPluginSummary(root: { plugin: string; path: string }): Promise<XcshPluginSummary> {
881
+ // Precedence mirrors MarketplaceManager#resolvePluginVersion.
882
+ const candidates: Array<{ rel: string; pick: (m: any) => { name?: string; description?: string } }> = [
883
+ { rel: ".xcsh-plugin/plugin.json", pick: m => ({ name: m?.name, description: m?.description }) },
884
+ {
885
+ rel: "package.json",
886
+ pick: m => ({ name: m?.xcsh?.name ?? m?.pi?.name, description: m?.xcsh?.description ?? m?.pi?.description }),
887
+ },
888
+ ];
889
+ for (const c of candidates) {
890
+ try {
891
+ const m = await Bun.file(path.join(root.path, c.rel)).json();
892
+ const { name, description } = c.pick(m);
893
+ if (name || description) {
894
+ return { id: root.plugin, name: name ?? root.plugin, description: sanitizePluginDescription(description) };
895
+ }
896
+ } catch {
897
+ // try next candidate
898
+ }
899
+ }
900
+ return { id: root.plugin, name: root.plugin, description: "" };
901
+ }
902
+
903
+ /** All installed plugins as {name, description}, sorted by name, deduped by plugin id. */
904
+ export async function listXcshPluginSummaries(home: string, cwd?: string): Promise<XcshPluginSummary[]> {
905
+ try {
906
+ const { roots } = await listXcshPluginRoots(home, cwd);
907
+ const seen = new Set<string>();
908
+ const out: XcshPluginSummary[] = [];
909
+ for (const r of roots) {
910
+ if (seen.has(r.plugin)) continue;
911
+ seen.add(r.plugin);
912
+ out.push(await readPluginSummary(r));
913
+ }
914
+ return out.sort((a, b) => a.name.localeCompare(b.name));
915
+ } catch {
916
+ return [];
917
+ }
918
+ }
919
+
859
920
  /**
860
921
  * Clear the plugin roots cache (useful for testing or when plugins change).
861
922
  */
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { readPluginSummary } from "./helpers";
6
+
7
+ async function mkRoot(files: Record<string, unknown>): Promise<string> {
8
+ const dir = await mkdtemp(join(tmpdir(), "xps-"));
9
+ for (const [rel, content] of Object.entries(files)) {
10
+ const p = join(dir, rel);
11
+ await mkdir(join(p, ".."), { recursive: true });
12
+ await writeFile(p, JSON.stringify(content));
13
+ }
14
+ return dir;
15
+ }
16
+
17
+ describe("readPluginSummary", () => {
18
+ test("reads name+description from .xcsh-plugin/plugin.json", async () => {
19
+ const path = await mkRoot({
20
+ ".xcsh-plugin/plugin.json": { name: "meddpicc", description: "MEDDPICC framework" },
21
+ });
22
+ expect(await readPluginSummary({ plugin: "meddpicc", path })).toEqual({
23
+ id: "meddpicc",
24
+ name: "meddpicc",
25
+ description: "MEDDPICC framework",
26
+ });
27
+ });
28
+ test("falls back to package.json xcsh field", async () => {
29
+ const path = await mkRoot({ "package.json": { xcsh: { name: "foo", description: "Foo tool" } } });
30
+ expect(await readPluginSummary({ plugin: "foo", path })).toEqual({
31
+ id: "foo",
32
+ name: "foo",
33
+ description: "Foo tool",
34
+ });
35
+ });
36
+ test("falls back to package.json pi field", async () => {
37
+ const path = await mkRoot({ "package.json": { pi: { name: "legacy", description: "Legacy tool" } } });
38
+ expect(await readPluginSummary({ plugin: "legacy", path })).toEqual({
39
+ id: "legacy",
40
+ name: "legacy",
41
+ description: "Legacy tool",
42
+ });
43
+ });
44
+ test("no manifest → name from root.plugin, empty description", async () => {
45
+ const path = await mkRoot({});
46
+ expect(await readPluginSummary({ plugin: "bare", path })).toEqual({
47
+ id: "bare",
48
+ name: "bare",
49
+ description: "",
50
+ });
51
+ });
52
+ test("manifest without name → falls back to root.plugin", async () => {
53
+ const path = await mkRoot({ ".xcsh-plugin/plugin.json": { description: "desc only" } });
54
+ expect(await readPluginSummary({ plugin: "baz", path })).toEqual({
55
+ id: "baz",
56
+ name: "baz",
57
+ description: "desc only",
58
+ });
59
+ });
60
+ test("id is the registry id even when the manifest name differs", async () => {
61
+ const path = await mkRoot({
62
+ ".xcsh-plugin/plugin.json": { name: "Display Name", description: "mismatched name" },
63
+ });
64
+ expect(await readPluginSummary({ plugin: "registry-id", path })).toEqual({
65
+ id: "registry-id",
66
+ name: "Display Name",
67
+ description: "mismatched name",
68
+ });
69
+ });
70
+ test("multi-line / oversized description is collapsed to one line and capped with …", async () => {
71
+ const longDescription = `line one\nline two\t${"x".repeat(400)}`;
72
+ const path = await mkRoot({
73
+ ".xcsh-plugin/plugin.json": { name: "big", description: longDescription },
74
+ });
75
+ const summary = await readPluginSummary({ plugin: "big", path });
76
+ expect(summary.id).toBe("big");
77
+ expect(summary.description).not.toContain("\n");
78
+ expect(summary.description).not.toContain("\t");
79
+ expect(summary.description.startsWith("line one line two ")).toBe(true);
80
+ expect(summary.description.length).toBe(301); // 300 chars + the "…" ellipsis
81
+ expect(summary.description.endsWith("…")).toBe(true);
82
+ });
83
+ });
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.75.0",
21
- "commit": "ac9635391c489c70d35c68a0e1cacdbae9f7e631",
22
- "shortCommit": "ac96353",
20
+ "version": "19.76.0",
21
+ "commit": "2e4e3b0d461f6dc353419167c54b0da3c7310765",
22
+ "shortCommit": "2e4e3b0",
23
23
  "branch": "main",
24
- "tag": "v19.75.0",
25
- "commitDate": "2026-07-22T13:46:55Z",
26
- "buildDate": "2026-07-22T14:09:19.426Z",
24
+ "tag": "v19.76.0",
25
+ "commitDate": "2026-07-22T16:43:35Z",
26
+ "buildDate": "2026-07-22T17:06:30.694Z",
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/ac9635391c489c70d35c68a0e1cacdbae9f7e631",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.75.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/2e4e3b0d461f6dc353419167c54b0da3c7310765",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.76.0"
33
33
  };
@@ -307,7 +307,10 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
307
307
  - `xcsh://console/<resource>/<operation>` — the exact ordered UI steps (selectors) for that operation.
308
308
  - `xcsh://extension` — Chrome extension bridge tool API reference: which tool to use (click, typeahead, input, navigation) for each automation task.
309
309
  {{#if hasPlugins}}
310
- Installed plugins expose capabilities, schemas, and executable helpers on demand. Read `xcsh://plugin` to list installed plugins and `xcsh://plugin/<name>` for a plugin's summary and how to go deeper pull this in only when a task matches a plugin's domain.
310
+ **Installed plugins** expose domain capabilities, schemas, and executable helpers on demand. When a task falls within a plugin's domain, consult that plugin: read `xcsh://plugin/<name>` for its summary, then **run its engine/helpers to produce any computed, scored, ranked, or "what to do next" result** rather than deriving it yourself or reading it from a data file a plugin's engine is the source of truth, and values already written into an artifact may be stale or wrong:
311
+ {{#each plugins}}
312
+ - **{{name}}** — {{description}} → `xcsh://plugin/{{id}}`
313
+ {{/each}}
311
314
  {{/if}}
312
315
 
313
316
  ### Presentation profile
@@ -3,36 +3,54 @@ import * as path from "node:path";
3
3
  import { prompt } from "@f5-sales-demo/pi-utils";
4
4
  import { registerCodingAgentPromptHelpers } from "./config/prompt-templates";
5
5
 
6
- // L0 of the MEDDPICC progressive-hints ladder: a single framework-agnostic line
7
- // telling the agent that installed plugins expose capabilities/schemas via
8
- // `xcsh://plugin/<name>`. It is gated on the `hasPlugins` render variable that
9
- // `buildSystemPrompt` computes from discovered plugin roots.
6
+ // Technique A of the MEDDPICC progressive-hints ladder: a generic, framework-agnostic
7
+ // "Installed Plugins" capability index. When ≥1 plugin is present, the template enumerates
8
+ // each installed plugin by name + own-manifest description + `xcsh://plugin/<name>` pointer,
9
+ // so the agent reliably consults the plugin instead of answering from memory. It is gated on
10
+ // the `hasPlugins` render variable and iterates the `plugins` array that `buildSystemPrompt`
11
+ // computes from discovered plugin summaries.
10
12
  //
11
- // Seam: the Handlebars-compile path (`prompt.render(template, data)`) — the same
12
- // seam every other conditional-rendering assertion in this package uses. It is
13
- // deterministic (no dependency on what is installed under the real home dir) and
14
- // exercises the exact template conditional this task adds. `buildSystemPrompt`
15
- // resolves plugins from `os.homedir()` and takes no `home` override, so a
16
- // true-case fixture through it would be non-deterministic.
13
+ // Seam: the Handlebars-compile path (`prompt.render(template, data)`) — the same seam every
14
+ // other conditional-rendering assertion in this package uses. It is deterministic (no
15
+ // dependency on what is installed under the real home dir) and exercises the exact template
16
+ // block this task renders. `buildSystemPrompt` resolves plugins from `os.homedir()` and takes
17
+ // no `home` override, so a true-case fixture through it would be non-deterministic.
17
18
 
18
19
  const systemPromptPath = path.resolve(import.meta.dir, "prompts/system/system-prompt.md");
19
20
 
20
- describe("L0 plugin-capability pointer", () => {
21
+ describe("Installed Plugins index (technique A)", () => {
21
22
  beforeAll(() => {
22
23
  registerCodingAgentPromptHelpers();
23
24
  });
24
25
 
25
- test("renders the generic pointer when a plugin is present, naming no plugin", async () => {
26
+ test("enumerates installed plugins with pointer, names no plugin in the core template", async () => {
26
27
  const template = await Bun.file(systemPromptPath).text();
27
- const rendered = prompt.render(template, { hasPlugins: true });
28
- expect(rendered).toContain("xcsh://plugin");
29
- // Generic/reusable: it must not name any specific plugin.
30
- expect(rendered).not.toContain("meddpicc");
28
+ const rendered = prompt.render(template, {
29
+ hasPlugins: true,
30
+ plugins: [{ id: "meddpicc", name: "meddpicc", description: "MEDDPICC qualification helper" }],
31
+ });
32
+ expect(rendered).toContain("Installed plugins");
33
+ expect(rendered).toContain("xcsh://plugin/meddpicc");
34
+ expect(rendered).toContain("MEDDPICC qualification helper");
31
35
  });
32
36
 
33
- test("omits the pointer entirely when no plugins are present", async () => {
37
+ test("pointer uses the registry id while the label uses the manifest name", async () => {
34
38
  const template = await Bun.file(systemPromptPath).text();
35
- const rendered = prompt.render(template, { hasPlugins: false });
39
+ const rendered = prompt.render(template, {
40
+ hasPlugins: true,
41
+ plugins: [{ id: "registry-id", name: "Display Name", description: "mismatched name and id" }],
42
+ });
43
+ // POINTER must resolve against the registry id, not the display name.
44
+ expect(rendered).toContain("xcsh://plugin/registry-id");
45
+ expect(rendered).not.toContain("xcsh://plugin/Display Name");
46
+ // DISPLAY label keeps the manifest name.
47
+ expect(rendered).toContain("**Display Name**");
48
+ });
49
+
50
+ test("omits the block entirely when no plugins are present", async () => {
51
+ const template = await Bun.file(systemPromptPath).text();
52
+ const rendered = prompt.render(template, { hasPlugins: false, plugins: [] });
53
+ expect(rendered).not.toContain("Installed plugins");
36
54
  expect(rendered).not.toContain("xcsh://plugin");
37
55
  });
38
56
  });
@@ -13,7 +13,7 @@ import { systemPromptCapability } from "./capability/system-prompt";
13
13
  import type { SkillsSettings } from "./config/settings";
14
14
  import { renderDeprecationGuardrails } from "./deprecations";
15
15
  import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery";
16
- import { listXcshPluginRoots } from "./discovery/helpers";
16
+ import { listXcshPluginSummaries, type XcshPluginSummary } from "./discovery/helpers";
17
17
  import { isApplicableToContext, loadSkills, type Skill } from "./extensibility/skills";
18
18
  import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
19
19
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
@@ -556,12 +556,13 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
556
556
  ? loadSkills({ ...mergedSkillsSettings, cwd: resolvedCwd }).then(result => result.skills)
557
557
  : Promise.resolve([]);
558
558
 
559
- // L0 plugin-capability pointer: gate a generic `xcsh://plugin` hint on ≥1
560
- // discoverable plugin. Discovery is cached and reads one small registry file,
561
- // so this is effectively free; it fails safe to `false` (no hint) on error.
562
- const hasPluginsPromise: Promise<boolean> = listXcshPluginRoots(os.homedir(), resolvedCwd)
563
- .then(result => result.roots.length > 0)
564
- .catch(() => false);
559
+ // Installed-plugins capability index: enumerate installed plugins (name + own-manifest
560
+ // description + xcsh://plugin/<name> pointer) so the agent reliably consults them. Discovery
561
+ // is cached; fails safe to [] (no block) on error.
562
+ const pluginSummariesPromise: Promise<XcshPluginSummary[]> = listXcshPluginSummaries(
563
+ os.homedir(),
564
+ resolvedCwd,
565
+ ).catch(() => []);
565
566
 
566
567
  return Promise.all([
567
568
  resolvePromptInput(customPrompt, "system prompt"),
@@ -570,7 +571,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
570
571
  contextFilesPromise,
571
572
  agentsMdSearchPromise,
572
573
  skillsPromise,
573
- hasPluginsPromise,
574
+ pluginSummariesPromise,
574
575
  ]).then(
575
576
  ([
576
577
  resolvedCustomPrompt,
@@ -579,7 +580,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
579
580
  contextFiles,
580
581
  agentsMdSearch,
581
582
  skills,
582
- hasPlugins,
583
+ pluginSummaries,
583
584
  ]) => ({
584
585
  resolvedCustomPrompt,
585
586
  resolvedAppendPrompt,
@@ -587,7 +588,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
587
588
  contextFiles,
588
589
  agentsMdSearch,
589
590
  skills,
590
- hasPlugins,
591
+ pluginSummaries,
591
592
  }),
592
593
  );
593
594
  })();
@@ -612,7 +613,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
612
613
  files: [],
613
614
  };
614
615
  let skills: Skill[] = providedSkills ?? [];
615
- let hasPlugins = false;
616
+ let pluginSummaries: XcshPluginSummary[] = [];
616
617
 
617
618
  if (prepResult.type === "timeout") {
618
619
  logger.warn("System prompt preparation timed out; using minimal startup context", {
@@ -635,9 +636,12 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
635
636
  contextFiles = dedupeExactContextFiles(prepResult.value.contextFiles);
636
637
  agentsMdSearch = prepResult.value.agentsMdSearch;
637
638
  skills = prepResult.value.skills;
638
- hasPlugins = prepResult.value.hasPlugins;
639
+ pluginSummaries = prepResult.value.pluginSummaries;
639
640
  }
640
641
 
642
+ const plugins = pluginSummaries;
643
+ const hasPlugins = plugins.length > 0;
644
+
641
645
  const date = new Date().toISOString().slice(0, 10);
642
646
  const dateTime = date;
643
647
  const promptCwd = resolvedCwd.replace(/\\/g, "/");
@@ -692,6 +696,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
692
696
  agentsMdSearch,
693
697
  skills: contextFilteredSkills,
694
698
  rules: rules ?? [],
699
+ plugins,
695
700
  hasPlugins,
696
701
  alwaysApplyRules: injectedAlwaysApplyRules,
697
702
  date,