@f5xc-salesdemos/xcsh 19.5.1 → 19.6.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": "@f5xc-salesdemos/xcsh",
4
- "version": "19.5.1",
4
+ "version": "19.6.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -50,12 +50,12 @@
50
50
  "dependencies": {
51
51
  "@agentclientprotocol/sdk": "0.16.1",
52
52
  "@mozilla/readability": "^0.6",
53
- "@f5xc-salesdemos/xcsh-stats": "19.5.1",
54
- "@f5xc-salesdemos/pi-agent-core": "19.5.1",
55
- "@f5xc-salesdemos/pi-ai": "19.5.1",
56
- "@f5xc-salesdemos/pi-natives": "19.5.1",
57
- "@f5xc-salesdemos/pi-tui": "19.5.1",
58
- "@f5xc-salesdemos/pi-utils": "19.5.1",
53
+ "@f5xc-salesdemos/xcsh-stats": "19.6.0",
54
+ "@f5xc-salesdemos/pi-agent-core": "19.6.0",
55
+ "@f5xc-salesdemos/pi-ai": "19.6.0",
56
+ "@f5xc-salesdemos/pi-natives": "19.6.0",
57
+ "@f5xc-salesdemos/pi-tui": "19.6.0",
58
+ "@f5xc-salesdemos/pi-utils": "19.6.0",
59
59
  "@sinclair/typebox": "^0.34",
60
60
  "@xterm/headless": "^6.0",
61
61
  "ajv": "^8.20",
@@ -19,6 +19,7 @@ import {
19
19
  listXcshPluginRoots,
20
20
  loadFilesFromDir,
21
21
  scanSkillsFromDir,
22
+ scopeToLevel,
22
23
  type XcshPluginRoot,
23
24
  } from "./helpers";
24
25
 
@@ -45,7 +46,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
45
46
  const result = await scanSkillsFromDir(ctx, {
46
47
  dir: skillsDir,
47
48
  providerId: PROVIDER_ID,
48
- level: root.scope,
49
+ level: scopeToLevel(root.scope),
49
50
  });
50
51
  return { root, result };
51
52
  }),
@@ -76,7 +77,7 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
76
77
  const results = await Promise.all(
77
78
  roots.map(async root => {
78
79
  const commandsDir = path.join(root.path, "commands");
79
- return loadFilesFromDir<SlashCommand>(ctx, commandsDir, PROVIDER_ID, root.scope, {
80
+ return loadFilesFromDir<SlashCommand>(ctx, commandsDir, PROVIDER_ID, scopeToLevel(root.scope), {
80
81
  extensions: ["md"],
81
82
  transform: (name, content, filePath, source) => {
82
83
  const cmdName = name.replace(/\.md$/, "");
@@ -84,7 +85,7 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
84
85
  name: root.plugin ? `${root.plugin}:${cmdName}` : cmdName,
85
86
  path: filePath,
86
87
  content,
87
- level: root.scope,
88
+ level: scopeToLevel(root.scope),
88
89
  _source: source,
89
90
  };
90
91
  },
@@ -123,7 +124,7 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
123
124
  const results = await Promise.all(
124
125
  loadTasks.map(async ({ root, hookType }) => {
125
126
  const hooksDir = path.join(root.path, "hooks", hookType);
126
- return loadFilesFromDir<Hook>(ctx, hooksDir, PROVIDER_ID, root.scope, {
127
+ return loadFilesFromDir<Hook>(ctx, hooksDir, PROVIDER_ID, scopeToLevel(root.scope), {
127
128
  transform: (name, _content, filePath, source) => {
128
129
  const toolName = name.replace(/\.(sh|bash|zsh|fish)$/, "");
129
130
  return {
@@ -131,7 +132,7 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
131
132
  path: filePath,
132
133
  type: hookType,
133
134
  tool: toolName,
134
- level: root.scope,
135
+ level: scopeToLevel(root.scope),
135
136
  _source: source,
136
137
  };
137
138
  },
@@ -161,14 +162,14 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
161
162
  const results = await Promise.all(
162
163
  roots.map(async root => {
163
164
  const toolsDir = path.join(root.path, "tools");
164
- return loadFilesFromDir<CustomTool>(ctx, toolsDir, PROVIDER_ID, root.scope, {
165
+ return loadFilesFromDir<CustomTool>(ctx, toolsDir, PROVIDER_ID, scopeToLevel(root.scope), {
165
166
  transform: (name, _content, filePath, source) => {
166
167
  const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
167
168
  return {
168
169
  name: toolName,
169
170
  path: filePath,
170
171
  description: `${toolName} custom tool`,
171
- level: root.scope,
172
+ level: scopeToLevel(root.scope),
172
173
  _source: source,
173
174
  };
174
175
  },
@@ -242,7 +243,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
242
243
  ...(raw.auth !== undefined && { auth: raw.auth }),
243
244
  ...(raw.oauth !== undefined && { oauth: raw.oauth }),
244
245
  ...(raw.type !== undefined && { transport: raw.type as MCPServer["transport"] }),
245
- _source: createSourceMeta(PROVIDER_ID, mcpPath, root.scope),
246
+ _source: createSourceMeta(PROVIDER_ID, mcpPath, scopeToLevel(root.scope)),
246
247
  };
247
248
  items.push(server);
248
249
  }
@@ -365,6 +365,22 @@ function expandEnvVars(value: string, extraEnv?: Record<string, string>): string
365
365
  });
366
366
  }
367
367
 
368
+ /**
369
+ * Build plugin-specific environment variables for variable substitution.
370
+ * These are injected as extraEnv when loading plugin configs (hooks, MCP, LSP).
371
+ */
372
+ export function buildPluginEnvVars(pluginRoot: string, pluginId: string, projectDir?: string): Record<string, string> {
373
+ const dataDir = path.join(os.homedir(), getConfigDirName(), "plugins", "data", pluginId.replace("@", "__"));
374
+ const vars: Record<string, string> = {
375
+ XCSH_PLUGIN_ROOT: pluginRoot,
376
+ XCSH_PLUGIN_DATA: dataDir,
377
+ };
378
+ if (projectDir) {
379
+ vars.XCSH_PROJECT_DIR = projectDir;
380
+ }
381
+ return vars;
382
+ }
383
+
368
384
  /**
369
385
  * Recursively expand environment variables in an object.
370
386
  */
@@ -621,8 +637,13 @@ export interface XcshPluginRoot {
621
637
  version: string;
622
638
  /** Absolute path to plugin root */
623
639
  path: string;
624
- /** Whether this is a user or project scope plugin */
625
- scope: "user" | "project";
640
+ /** Whether this is a user, project, or local scope plugin */
641
+ scope: "user" | "project" | "local";
642
+ }
643
+
644
+ /** Map plugin scope to the loading level — "local" behaves as "project" for capability loading. */
645
+ export function scopeToLevel(scope: "user" | "project" | "local"): "user" | "project" {
646
+ return scope === "local" ? "project" : scope;
626
647
  }
627
648
 
628
649
  /**
@@ -277,8 +277,9 @@ async function cloneAndReadCatalog(url: string, cacheDir: string): Promise<Fetch
277
277
  const tmpDir = path.join(cacheDir, `.tmp-clone-${Date.now()}`);
278
278
  await fs.mkdir(cacheDir, { recursive: true });
279
279
 
280
- logger.debug(`[marketplace] cloning ${url} ${tmpDir}`);
281
- await git.clone(url, tmpDir);
280
+ const timeoutMs = Number(Bun.env.XCSH_PLUGIN_GIT_TIMEOUT_MS) || 120_000;
281
+ logger.debug(`[marketplace] cloning ${url} → ${tmpDir} (timeout: ${timeoutMs}ms)`);
282
+ await git.clone(url, tmpDir, { signal: AbortSignal.timeout(timeoutMs) });
282
283
 
283
284
  const catalogPath = path.join(tmpDir, CATALOG_RELATIVE_PATH);
284
285
  let content: string;
@@ -49,6 +49,11 @@ export interface MarketplaceManagerOptions {
49
49
  * Resolved by resolveActiveProjectRegistryPath(cwd) in callers.
50
50
  */
51
51
  projectInstalledRegistryPath?: string;
52
+ /**
53
+ * Path to the local-scoped installed_plugins.json (gitignored, project-specific).
54
+ * Same as project path but under a `.local` variant so it stays out of VCS.
55
+ */
56
+ localInstalledRegistryPath?: string;
52
57
  marketplacesCacheDir: string;
53
58
  pluginsCacheDir: string;
54
59
  /** Injected for testing; production callers pass clearXcshPluginRootsCache.
@@ -143,7 +148,17 @@ export class MarketplaceManager {
143
148
  throw new Error(`Marketplace "${name}" not found`);
144
149
  }
145
150
 
146
- const { catalog, clonePath } = await fetchMarketplace(existing.sourceUri, this.#opts.marketplacesCacheDir);
151
+ let fetchResult: { catalog: MarketplaceCatalog; clonePath?: string };
152
+ try {
153
+ fetchResult = await fetchMarketplace(existing.sourceUri, this.#opts.marketplacesCacheDir);
154
+ } catch (err) {
155
+ if (Bun.env.XCSH_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE === "1") {
156
+ logger.debug("Marketplace fetch failed, preserving cached catalog", { name, error: String(err) });
157
+ return existing;
158
+ }
159
+ throw err;
160
+ }
161
+ const { catalog, clonePath } = fetchResult;
147
162
 
148
163
  // Guard against upstream catalog silently renaming itself — the registry
149
164
  // entry is keyed by name, so a drift would corrupt the entry on next read.
@@ -227,7 +242,7 @@ export class MarketplaceManager {
227
242
  async installPlugin(
228
243
  name: string,
229
244
  marketplace: string,
230
- options?: { force?: boolean; scope?: "user" | "project" },
245
+ options?: { force?: boolean; scope?: "user" | "project" | "local" },
231
246
  ): Promise<InstalledPluginEntry> {
232
247
  const force = options?.force ?? false;
233
248
  const scope = options?.scope ?? "user";
@@ -378,7 +393,7 @@ export class MarketplaceManager {
378
393
  return "0.0.0";
379
394
  }
380
395
 
381
- async uninstallPlugin(pluginId: string, scope?: "user" | "project"): Promise<void> {
396
+ async uninstallPlugin(pluginId: string, scope?: "user" | "project" | "local"): Promise<void> {
382
397
  const parsed = parsePluginId(pluginId);
383
398
  if (!parsed) {
384
399
  throw new Error(`Invalid plugin ID format: "${pluginId}". Expected "name@marketplace".`);
@@ -394,7 +409,7 @@ export class MarketplaceManager {
394
409
  }
395
410
 
396
411
  // Disambiguation: if installed in both scopes and no explicit scope, require one.
397
- let targetScope: "user" | "project";
412
+ let targetScope: "user" | "project" | "local";
398
413
  if (inUser && inProject) {
399
414
  if (!scope) {
400
415
  throw new Error(
@@ -478,7 +493,7 @@ export class MarketplaceManager {
478
493
  return results;
479
494
  }
480
495
 
481
- async setPluginEnabled(pluginId: string, enabled: boolean, scope?: "user" | "project"): Promise<void> {
496
+ async setPluginEnabled(pluginId: string, enabled: boolean, scope?: "user" | "project" | "local"): Promise<void> {
482
497
  const { userEntries, projectEntries, userReg, projectReg } = await this.#findInBothRegistries(pluginId);
483
498
 
484
499
  const inUser = userEntries && userEntries.length > 0;
@@ -489,7 +504,7 @@ export class MarketplaceManager {
489
504
  }
490
505
 
491
506
  // Disambiguation: if installed in both scopes and no explicit scope, require one.
492
- let targetScope: "user" | "project";
507
+ let targetScope: "user" | "project" | "local";
493
508
  if (inUser && inProject) {
494
509
  if (!scope) {
495
510
  throw new Error(
@@ -548,16 +563,23 @@ export class MarketplaceManager {
548
563
  // Compare installed plugin versions against their catalog entries.
549
564
  // Returns one entry per (pluginId, scope) pair where the catalog declares a newer version.
550
565
  // Catalog entries without a version field are skipped.
551
- async checkForUpdates(): Promise<Array<{ pluginId: string; scope: "user" | "project"; from: string; to: string }>> {
566
+ async checkForUpdates(): Promise<
567
+ Array<{ pluginId: string; scope: "user" | "project" | "local"; from: string; to: string }>
568
+ > {
552
569
  const mktReg = await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath);
553
- const updates: Array<{ pluginId: string; scope: "user" | "project"; from: string; to: string }> = [];
570
+ const updates: Array<{ pluginId: string; scope: "user" | "project" | "local"; from: string; to: string }> = [];
554
571
 
555
572
  // Keyed by (path, scope) so each scope is checked independently.
556
573
  // A plugin current in user scope but stale in project scope must still appear.
557
- const registryEntries: Array<[string, "user" | "project"]> = [[this.#opts.installedRegistryPath, "user"]];
574
+ const registryEntries: Array<[string, "user" | "project" | "local"]> = [
575
+ [this.#opts.installedRegistryPath, "user"],
576
+ ];
558
577
  if (this.#opts.projectInstalledRegistryPath) {
559
578
  registryEntries.push([this.#opts.projectInstalledRegistryPath, "project"]);
560
579
  }
580
+ if (this.#opts.localInstalledRegistryPath) {
581
+ registryEntries.push([this.#opts.localInstalledRegistryPath, "local"]);
582
+ }
561
583
 
562
584
  for (const [regPath, scope] of registryEntries) {
563
585
  const instReg = await readInstalledPluginsRegistry(regPath);
@@ -598,7 +620,7 @@ export class MarketplaceManager {
598
620
  }
599
621
 
600
622
  // Re-install a specific plugin at the latest catalog version (force-overwrites).
601
- async upgradePlugin(pluginId: string, scope?: "user" | "project"): Promise<InstalledPluginEntry> {
623
+ async upgradePlugin(pluginId: string, scope?: "user" | "project" | "local"): Promise<InstalledPluginEntry> {
602
624
  const parsed = parsePluginId(pluginId);
603
625
  if (!parsed) {
604
626
  throw new Error(`Invalid plugin ID: "${pluginId}". Expected "name@marketplace".`);
@@ -613,7 +635,7 @@ export class MarketplaceManager {
613
635
  throw new Error(`Plugin "${pluginId}" is not installed`);
614
636
  }
615
637
 
616
- let resolvedScope: "user" | "project";
638
+ let resolvedScope: "user" | "project" | "local";
617
639
  if (inUser && inProject) {
618
640
  if (!scope) {
619
641
  throw new Error(
@@ -667,10 +689,10 @@ export class MarketplaceManager {
667
689
  // Only stale scopes are touched; a current user install is not re-installed when only
668
690
  // the project scope is stale. Per-entry failures are skipped — partial success is returned.
669
691
  async upgradeAllPlugins(): Promise<
670
- Array<{ pluginId: string; scope: "user" | "project"; from: string; to: string }>
692
+ Array<{ pluginId: string; scope: "user" | "project" | "local"; from: string; to: string }>
671
693
  > {
672
694
  const updates = await this.checkForUpdates();
673
- const results: Array<{ pluginId: string; scope: "user" | "project"; from: string; to: string }> = [];
695
+ const results: Array<{ pluginId: string; scope: "user" | "project" | "local"; from: string; to: string }> = [];
674
696
  for (const update of updates) {
675
697
  try {
676
698
  const entry = await this.upgradePlugin(update.pluginId, update.scope);
@@ -684,13 +706,19 @@ export class MarketplaceManager {
684
706
 
685
707
  // ── Private helpers ───────────────────────────────────────────────────────
686
708
 
687
- #registryPath(scope: "user" | "project"): string {
709
+ #registryPath(scope: "user" | "project" | "local"): string {
688
710
  if (scope === "project") {
689
711
  if (!this.#opts.projectInstalledRegistryPath) {
690
712
  throw new Error("project-scoped install requires running inside a project directory");
691
713
  }
692
714
  return this.#opts.projectInstalledRegistryPath;
693
715
  }
716
+ if (scope === "local") {
717
+ if (!this.#opts.localInstalledRegistryPath) {
718
+ throw new Error("local-scoped install requires running inside a project directory");
719
+ }
720
+ return this.#opts.localInstalledRegistryPath;
721
+ }
694
722
  return this.#opts.installedRegistryPath;
695
723
  }
696
724
 
@@ -163,7 +163,7 @@ export interface InstalledPluginsRegistry {
163
163
  }
164
164
 
165
165
  export interface InstalledPluginEntry {
166
- scope: "user" | "project";
166
+ scope: "user" | "project" | "local";
167
167
  /** Absolute path to cached plugin directory. */
168
168
  installPath: string;
169
169
  version: string;
@@ -186,7 +186,7 @@ export interface InstalledPluginEntry {
186
186
  */
187
187
  export interface InstalledPluginSummary {
188
188
  id: string;
189
- scope: "user" | "project";
189
+ scope: "user" | "project" | "local";
190
190
  entries: InstalledPluginEntry[];
191
191
  /** Set when a user-scoped plugin is overridden by a project-scoped install. */
192
192
  shadowedBy?: "project";
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.5.1",
21
- "commit": "9c15a98f105051030aa1e0f5376d315d0214e8fd",
22
- "shortCommit": "9c15a98",
20
+ "version": "19.6.0",
21
+ "commit": "8505c9827499b1e269615db372fec4132b61c506",
22
+ "shortCommit": "8505c98",
23
23
  "branch": "main",
24
- "tag": "v19.5.1",
25
- "commitDate": "2026-06-04T17:32:07Z",
26
- "buildDate": "2026-06-04T17:56:43.676Z",
24
+ "tag": "v19.6.0",
25
+ "commitDate": "2026-06-04T18:26:24Z",
26
+ "buildDate": "2026-06-04T18:51:12.169Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
30
30
  "repoSlug": "f5xc-salesdemos/xcsh",
31
- "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/9c15a98f105051030aa1e0f5376d315d0214e8fd",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.5.1"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/8505c9827499b1e269615db372fec4132b61c506",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.6.0"
33
33
  };
@@ -17,7 +17,7 @@ export interface PluginItem {
17
17
  plugin: { name: string; version?: string; description?: string };
18
18
  marketplace: string;
19
19
  /** Scope of this entry. When set, appended to the label and forwarded to onSelect. */
20
- scope?: "user" | "project";
20
+ scope?: "user" | "project" | "local";
21
21
  }
22
22
 
23
23
  export class PluginSelectorComponent extends Container {
@@ -4,7 +4,7 @@ export interface DashboardPlugin {
4
4
  displayName?: string;
5
5
  marketplace?: string;
6
6
  source: "npm" | "marketplace";
7
- scope?: "user" | "project";
7
+ scope?: "user" | "project" | "local";
8
8
  version?: string;
9
9
  catalogVersion?: string;
10
10
  description?: string;