@oh-my-pi/pi-coding-agent 13.16.4 → 13.17.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/package.json +7 -7
  3. package/src/cli/args.ts +7 -0
  4. package/src/cli/classify-install-target.ts +50 -0
  5. package/src/cli/plugin-cli.ts +245 -31
  6. package/src/commands/plugin.ts +3 -0
  7. package/src/config/model-registry.ts +37 -0
  8. package/src/config/model-resolver.ts +18 -3
  9. package/src/config/settings-schema.ts +24 -13
  10. package/src/cursor.ts +66 -1
  11. package/src/discovery/claude-plugins.ts +95 -5
  12. package/src/discovery/helpers.ts +168 -41
  13. package/src/discovery/plugin-dir-roots.ts +28 -0
  14. package/src/discovery/substitute-plugin-root.ts +29 -0
  15. package/src/extensibility/plugins/index.ts +1 -0
  16. package/src/extensibility/plugins/marketplace/cache.ts +136 -0
  17. package/src/extensibility/plugins/marketplace/fetcher.ts +354 -0
  18. package/src/extensibility/plugins/marketplace/index.ts +6 -0
  19. package/src/extensibility/plugins/marketplace/manager.ts +528 -0
  20. package/src/extensibility/plugins/marketplace/registry.ts +181 -0
  21. package/src/extensibility/plugins/marketplace/source-resolver.ts +147 -0
  22. package/src/extensibility/plugins/marketplace/types.ts +177 -0
  23. package/src/extensibility/skills.ts +3 -3
  24. package/src/internal-urls/index.ts +1 -0
  25. package/src/internal-urls/local-protocol.ts +2 -19
  26. package/src/internal-urls/parse.ts +72 -0
  27. package/src/internal-urls/router.ts +2 -18
  28. package/src/lsp/config.ts +9 -0
  29. package/src/main.ts +50 -1
  30. package/src/modes/components/plugin-selector.ts +86 -0
  31. package/src/modes/components/settings-defs.ts +9 -4
  32. package/src/modes/controllers/event-controller.ts +10 -0
  33. package/src/modes/controllers/mcp-command-controller.ts +14 -0
  34. package/src/modes/controllers/selector-controller.ts +104 -13
  35. package/src/modes/interactive-mode.ts +9 -0
  36. package/src/modes/types.ts +1 -0
  37. package/src/prompts/agents/reviewer.md +3 -4
  38. package/src/prompts/tools/bash.md +3 -3
  39. package/src/sdk.ts +0 -7
  40. package/src/session/agent-session.ts +292 -6
  41. package/src/slash-commands/builtin-registry.ts +273 -0
  42. package/src/tools/bash-skill-urls.ts +48 -5
  43. package/src/tools/bash.ts +2 -0
  44. package/src/tools/read.ts +15 -9
  45. package/src/web/search/code-search.ts +2 -179
  46. package/src/web/search/index.ts +2 -3
  47. package/src/web/search/types.ts +1 -5
@@ -1,6 +1,20 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+
1
4
  import { getOAuthProviders } from "@oh-my-pi/pi-ai";
5
+ import { getConfigDirName } from "@oh-my-pi/pi-utils";
6
+ import { invalidate as invalidateFsCache } from "../capability/fs";
2
7
  import type { SettingPath, SettingValue } from "../config/settings";
3
8
  import { settings } from "../config/settings";
9
+ import { clearClaudePluginRootsCache } from "../discovery/helpers.js";
10
+ import { PluginManager } from "../extensibility/plugins";
11
+ import {
12
+ getInstalledPluginsRegistryPath,
13
+ getMarketplacesCacheDir,
14
+ getMarketplacesRegistryPath,
15
+ getPluginsCacheDir,
16
+ MarketplaceManager,
17
+ } from "../extensibility/plugins/marketplace";
4
18
  import type { InteractiveModeContext } from "../modes/types";
5
19
 
6
20
  function refreshStatusLine(ctx: InteractiveModeContext): void {
@@ -543,6 +557,265 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
543
557
  description: "Exit the application",
544
558
  handle: shutdownHandler,
545
559
  },
560
+ {
561
+ name: "marketplace",
562
+ description: "Manage marketplace plugin sources and installed plugins",
563
+ subcommands: [
564
+ { name: "add", description: "Add a marketplace source", usage: "<source>" },
565
+ { name: "remove", description: "Remove a marketplace source", usage: "<name>" },
566
+ { name: "update", description: "Update marketplace catalog(s)", usage: "[name]" },
567
+ { name: "list", description: "List configured marketplaces" },
568
+ { name: "discover", description: "Browse available plugins", usage: "[marketplace]" },
569
+ {
570
+ name: "install",
571
+ description: "Install a plugin (interactive browser if no args)",
572
+ usage: "[--force] [name@marketplace]",
573
+ },
574
+ { name: "uninstall", description: "Uninstall a plugin (selector if no args)", usage: "[name@marketplace]" },
575
+ { name: "installed", description: "List installed marketplace plugins" },
576
+ { name: "upgrade", description: "Upgrade outdated plugins", usage: "[name@marketplace]" },
577
+ ],
578
+ allowArgs: true,
579
+ handle: async (command, runtime) => {
580
+ runtime.ctx.editor.setText("");
581
+ const args = command.args.trim().split(/\s+/);
582
+ const sub = args[0] || "install";
583
+ const rest = args.slice(1).join(" ").trim();
584
+
585
+ // /marketplace (no args) or /marketplace install (no args) → interactive browser
586
+ if ((sub === "install" && !rest) || (!args[0] && !command.args.trim())) {
587
+ try {
588
+ runtime.ctx.showPluginSelector("install");
589
+ } catch (err) {
590
+ runtime.ctx.showStatus(`Marketplace error: ${err}`);
591
+ }
592
+ return;
593
+ }
594
+
595
+ const mgr = new MarketplaceManager({
596
+ marketplacesRegistryPath: getMarketplacesRegistryPath(),
597
+ installedRegistryPath: getInstalledPluginsRegistryPath(),
598
+ marketplacesCacheDir: getMarketplacesCacheDir(),
599
+ pluginsCacheDir: getPluginsCacheDir(),
600
+ clearPluginRootsCache: () => {
601
+ const home = os.homedir();
602
+ invalidateFsCache(path.join(home, ".claude", "plugins", "installed_plugins.json"));
603
+ invalidateFsCache(path.join(home, getConfigDirName(), "plugins", "installed_plugins.json"));
604
+ clearClaudePluginRootsCache();
605
+ },
606
+ });
607
+
608
+ try {
609
+ switch (sub) {
610
+ case "add": {
611
+ if (!rest) {
612
+ runtime.ctx.showStatus("Usage: /marketplace add <source>");
613
+ return;
614
+ }
615
+ const entry = await mgr.addMarketplace(rest);
616
+ runtime.ctx.showStatus(`Added marketplace: ${entry.name}`);
617
+ break;
618
+ }
619
+ case "remove":
620
+ case "rm": {
621
+ if (!rest) {
622
+ runtime.ctx.showStatus("Usage: /marketplace remove <name>");
623
+ return;
624
+ }
625
+ await mgr.removeMarketplace(rest);
626
+ runtime.ctx.showStatus(`Removed marketplace: ${rest}`);
627
+ break;
628
+ }
629
+ case "update": {
630
+ if (rest) {
631
+ await mgr.updateMarketplace(rest);
632
+ runtime.ctx.showStatus(`Updated marketplace: ${rest}`);
633
+ } else {
634
+ const results = await mgr.updateAllMarketplaces();
635
+ runtime.ctx.showStatus(`Updated ${results.length} marketplace(s)`);
636
+ }
637
+ break;
638
+ }
639
+ case "discover": {
640
+ const plugins = await mgr.listAvailablePlugins(rest || undefined);
641
+ if (plugins.length === 0) {
642
+ runtime.ctx.showStatus("No plugins available");
643
+ } else {
644
+ const lines = plugins.map(
645
+ p =>
646
+ ` ${p.name}${p.version ? `@${p.version}` : ""}${p.description ? ` - ${p.description}` : ""}`,
647
+ );
648
+ runtime.ctx.showStatus(`Available plugins:\n${lines.join("\n")}`);
649
+ }
650
+ break;
651
+ }
652
+ case "install": {
653
+ // Parse: /marketplace install [--force] name@marketplace
654
+ const force = rest.startsWith("--force ");
655
+ const installSpec = force ? rest.slice("--force ".length).trim() : rest;
656
+ if (!installSpec?.includes("@")) {
657
+ runtime.ctx.showStatus("Usage: /marketplace install [--force] <name@marketplace>");
658
+ return;
659
+ }
660
+ const atIdx = installSpec.lastIndexOf("@");
661
+ const name = installSpec.slice(0, atIdx);
662
+ const marketplace = installSpec.slice(atIdx + 1);
663
+ await mgr.installPlugin(name, marketplace, { force });
664
+ runtime.ctx.showStatus(`Installed ${name} from ${marketplace}`);
665
+ break;
666
+ }
667
+ case "uninstall": {
668
+ if (!rest) {
669
+ // No args → open interactive uninstall selector
670
+ runtime.ctx.showPluginSelector("uninstall");
671
+ return;
672
+ }
673
+ await mgr.uninstallPlugin(rest);
674
+ runtime.ctx.showStatus(`Uninstalled ${rest}`);
675
+ break;
676
+ }
677
+ case "installed": {
678
+ const installed = await mgr.listInstalledPlugins();
679
+ if (installed.length === 0) {
680
+ runtime.ctx.showStatus("No marketplace plugins installed");
681
+ } else {
682
+ const lines = installed.map(p => ` ${p.id} (${p.entries.length} entry)`);
683
+ runtime.ctx.showStatus(`Installed plugins:\n${lines.join("\n")}`);
684
+ }
685
+ break;
686
+ }
687
+ case "upgrade": {
688
+ if (rest) {
689
+ const result = await mgr.upgradePlugin(rest);
690
+ runtime.ctx.showStatus(`Upgraded ${rest} to ${result.version}`);
691
+ } else {
692
+ const results = await mgr.upgradeAllPlugins();
693
+ if (results.length === 0) {
694
+ runtime.ctx.showStatus("All marketplace plugins are up to date");
695
+ } else {
696
+ const lines = results.map(r => ` ${r.pluginId}: ${r.from} -> ${r.to}`);
697
+ runtime.ctx.showStatus(`Upgraded ${results.length} plugin(s):\n${lines.join("\n")}`);
698
+ }
699
+ }
700
+ break;
701
+ }
702
+ default: {
703
+ // Default to list marketplaces
704
+ const marketplaces = await mgr.listMarketplaces();
705
+ if (marketplaces.length === 0) {
706
+ runtime.ctx.showStatus("No marketplaces configured. Use /marketplace add <source>");
707
+ } else {
708
+ const lines = marketplaces.map(m => ` ${m.name} ${m.sourceUri}`);
709
+ runtime.ctx.showStatus(`Marketplaces:\n${lines.join("\n")}`);
710
+ }
711
+ break;
712
+ }
713
+ }
714
+ } catch (err) {
715
+ runtime.ctx.showStatus(`Marketplace error: ${err}`);
716
+ }
717
+ },
718
+ },
719
+ {
720
+ name: "plugins",
721
+ description: "View and manage installed plugins",
722
+ subcommands: [
723
+ { name: "list", description: "List all installed plugins (npm + marketplace)" },
724
+ { name: "enable", description: "Enable a marketplace plugin", usage: "<name@marketplace>" },
725
+ { name: "disable", description: "Disable a marketplace plugin", usage: "<name@marketplace>" },
726
+ ],
727
+ allowArgs: true,
728
+ handle: async (command, runtime) => {
729
+ runtime.ctx.editor.setText("");
730
+ const args = command.args.trim().split(/\s+/);
731
+ const sub = args[0] || "list";
732
+ const rest = args.slice(1).join(" ").trim();
733
+
734
+ try {
735
+ const mgr = new MarketplaceManager({
736
+ marketplacesRegistryPath: getMarketplacesRegistryPath(),
737
+ installedRegistryPath: getInstalledPluginsRegistryPath(),
738
+ marketplacesCacheDir: getMarketplacesCacheDir(),
739
+ pluginsCacheDir: getPluginsCacheDir(),
740
+ clearPluginRootsCache: () => {
741
+ const home = os.homedir();
742
+ invalidateFsCache(path.join(home, ".claude", "plugins", "installed_plugins.json"));
743
+ invalidateFsCache(path.join(home, getConfigDirName(), "plugins", "installed_plugins.json"));
744
+ clearClaudePluginRootsCache();
745
+ },
746
+ });
747
+
748
+ switch (sub) {
749
+ case "enable": {
750
+ if (!rest) {
751
+ runtime.ctx.showStatus("Usage: /plugins enable <name@marketplace>");
752
+ return;
753
+ }
754
+ await mgr.setPluginEnabled(rest, true);
755
+ runtime.ctx.showStatus(`Enabled ${rest}`);
756
+ break;
757
+ }
758
+ case "disable": {
759
+ if (!rest) {
760
+ runtime.ctx.showStatus("Usage: /plugins disable <name@marketplace>");
761
+ return;
762
+ }
763
+ await mgr.setPluginEnabled(rest, false);
764
+ runtime.ctx.showStatus(`Disabled ${rest}`);
765
+ break;
766
+ }
767
+ default: {
768
+ const lines: string[] = [];
769
+
770
+ const npm = new PluginManager();
771
+ const npmPlugins = await npm.list();
772
+ if (npmPlugins.length > 0) {
773
+ lines.push("npm plugins:");
774
+ for (const p of npmPlugins) {
775
+ const status = p.enabled === false ? " (disabled)" : "";
776
+ lines.push(` ${p.name}@${p.version}${status}`);
777
+ }
778
+ }
779
+
780
+ const mktPlugins = await mgr.listInstalledPlugins();
781
+ if (mktPlugins.length > 0) {
782
+ if (lines.length > 0) lines.push("");
783
+ lines.push("marketplace plugins:");
784
+ for (const p of mktPlugins) {
785
+ const entry = p.entries[0];
786
+ const status = entry?.enabled === false ? " (disabled)" : "";
787
+ lines.push(` ${p.id} v${entry?.version ?? "?"}${status}`);
788
+ }
789
+ }
790
+
791
+ if (lines.length === 0) {
792
+ runtime.ctx.showStatus("No plugins installed");
793
+ } else {
794
+ runtime.ctx.showStatus(lines.join("\n"));
795
+ }
796
+ break;
797
+ }
798
+ }
799
+ } catch (err) {
800
+ runtime.ctx.showStatus(`Plugin error: ${err}`);
801
+ }
802
+ },
803
+ },
804
+ {
805
+ name: "reload-plugins",
806
+ description: "Reload all plugins (skills, commands, hooks, tools, agents, MCP)",
807
+ handle: async (_command, runtime) => {
808
+ // Invalidate the fs content cache for both registry files so
809
+ // listClaudePluginRoots re-reads from disk on next access.
810
+ const home = os.homedir();
811
+ invalidateFsCache(path.join(home, ".claude", "plugins", "installed_plugins.json"));
812
+ invalidateFsCache(path.join(home, getConfigDirName(), "plugins", "installed_plugins.json"));
813
+ clearClaudePluginRootsCache();
814
+ await runtime.ctx.refreshSlashCommandState();
815
+ runtime.ctx.showStatus("Plugins reloaded.");
816
+ runtime.ctx.editor.setText("");
817
+ },
818
+ },
546
819
  {
547
820
  name: "quit",
548
821
  description: "Quit the application",
@@ -40,19 +40,29 @@ export function resolveSkillUrlToPath(url: string, skills: readonly Skill[]): st
40
40
  throw new ToolError(`Invalid skill:// URL: ${url}`);
41
41
  }
42
42
 
43
- const skillName = parsed[1];
44
- if (!skillName) {
43
+ let rawSkillSegment = parsed[1];
44
+ if (!rawSkillSegment) {
45
45
  throw new ToolError(`skill:// URL requires a skill name: ${url}`);
46
46
  }
47
+ // Decode percent-encoded colons (%3A) used for namespaced skill names
48
+ try {
49
+ rawSkillSegment = decodeURIComponent(rawSkillSegment);
50
+ } catch {
51
+ // Leave as-is if decoding fails
52
+ }
47
53
 
48
- const rawPath = parsed[2] ?? "";
49
- const skill = skills.find(s => s.name === skillName);
54
+ // Resolve skill name by longest-prefix match against registered skills.
55
+ // This handles namespaced skills ("plugin:skill") where the URI may also
56
+ // carry a colon-delimited suffix (e.g., ":1-5" line range).
57
+ const { skill, suffix } = matchSkillName(rawSkillSegment, skills);
50
58
  if (!skill) {
51
59
  const available = skills.map(s => s.name);
52
60
  const availableStr = available.length > 0 ? available.join(", ") : "none";
53
- throw new ToolError(`Unknown skill: ${skillName}. Available: ${availableStr}`);
61
+ throw new ToolError(`Unknown skill: ${rawSkillSegment}. Available: ${availableStr}`);
54
62
  }
55
63
 
64
+ // Combine any colon suffix (line range like ":1-5") with the path segment
65
+ const rawPath = (parsed[2] ?? "") + (suffix ? `/${suffix}` : "");
56
66
  const hasRelativePath = rawPath !== "" && rawPath !== "/";
57
67
 
58
68
  if (!hasRelativePath) {
@@ -82,6 +92,39 @@ export function resolveSkillUrlToPath(url: string, skills: readonly Skill[]): st
82
92
  return resolvedPath;
83
93
  }
84
94
 
95
+ /**
96
+ * Match a raw skill segment against registered skills using longest-prefix match.
97
+ * Handles colons in both skill names (namespacing) and suffixes (line ranges).
98
+ *
99
+ * For "superpowers:brainstorming:1-5" with skill "superpowers:brainstorming":
100
+ * -> skill = superpowers:brainstorming, suffix = "1-5"
101
+ * For "brainstorming" with skill "brainstorming":
102
+ * -> skill = brainstorming, suffix = undefined
103
+ */
104
+ function matchSkillName(
105
+ rawSegment: string,
106
+ skills: readonly Skill[],
107
+ ): { skill: Skill | undefined; suffix: string | undefined } {
108
+ // Exact match first (most common case)
109
+ const exact = skills.find(s => s.name === rawSegment);
110
+ if (exact) return { skill: exact, suffix: undefined };
111
+
112
+ // Try stripping colon-delimited suffixes from the right
113
+ let candidate = rawSegment;
114
+ while (true) {
115
+ const lastColon = candidate.lastIndexOf(":");
116
+ if (lastColon <= 0) break;
117
+ candidate = candidate.slice(0, lastColon);
118
+ const match = skills.find(s => s.name === candidate);
119
+ if (match) {
120
+ const suffix = rawSegment.slice(lastColon + 1);
121
+ return { skill: match, suffix };
122
+ }
123
+ }
124
+
125
+ return { skill: undefined, suffix: undefined };
126
+ }
127
+
85
128
  function extractScheme(url: string): SupportedInternalScheme | undefined {
86
129
  const match = /^([a-z][a-z0-9+.-]*):\/\//i.exec(url);
87
130
  if (!match) return undefined;
package/src/tools/bash.ts CHANGED
@@ -222,6 +222,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
222
222
  asyncEnabled: this.#asyncEnabled,
223
223
  hasAstGrep: this.session.settings.get("astGrep.enabled"),
224
224
  hasAstEdit: this.session.settings.get("astEdit.enabled"),
225
+ hasGrep: this.session.settings.get("grep.enabled"),
226
+ hasFind: this.session.settings.get("find.enabled"),
225
227
  });
226
228
  }
227
229
 
package/src/tools/read.ts CHANGED
@@ -9,6 +9,8 @@ import { getRemoteDir, ptree, untilAborted } from "@oh-my-pi/pi-utils";
9
9
  import { type Static, Type } from "@sinclair/typebox";
10
10
  import { renderPromptTemplate } from "../config/prompt-templates";
11
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
+ import { parseInternalUrl } from "../internal-urls/parse";
13
+ import type { InternalUrl } from "../internal-urls/types";
12
14
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
13
15
  import { computeLineHash } from "../patch/hashline";
14
16
  import readDescription from "../prompts/tools/read.md" with { type: "text" };
@@ -686,18 +688,22 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
686
688
  async #handleInternalUrl(url: string, offset?: number, limit?: number): Promise<AgentToolResult<ReadToolDetails>> {
687
689
  const internalRouter = this.session.internalRouter!;
688
690
 
689
- // Check if URL has query extraction (agent:// only)
690
- let parsed: URL;
691
+ // Check if URL has query extraction (agent:// only).
692
+ // Use parseInternalUrl which handles colons in host (namespaced skills).
693
+ let parsed: InternalUrl;
691
694
  try {
692
- parsed = new URL(url);
693
- } catch {
694
- throw new ToolError(`Invalid URL: ${url}`);
695
+ parsed = parseInternalUrl(url);
696
+ } catch (e) {
697
+ throw new ToolError(e instanceof Error ? e.message : String(e));
695
698
  }
696
699
  const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
697
- const hasPathExtraction = parsed.pathname && parsed.pathname !== "/" && parsed.pathname !== "";
698
- const queryParam = parsed.searchParams.get("q");
699
- const hasQueryExtraction = scheme === "agent" && queryParam !== null && queryParam !== "";
700
- const hasExtraction = scheme === "agent" && (hasPathExtraction || hasQueryExtraction);
700
+ let hasExtraction = false;
701
+ if (scheme === "agent") {
702
+ const hasPathExtraction = parsed.pathname && parsed.pathname !== "/" && parsed.pathname !== "";
703
+ const queryParam = parsed.searchParams.get("q");
704
+ const hasQueryExtraction = queryParam !== null && queryParam !== "";
705
+ hasExtraction = hasPathExtraction || hasQueryExtraction;
706
+ }
701
707
 
702
708
  // Reject offset/limit with query extraction
703
709
  if (hasExtraction && (offset !== undefined || limit !== undefined)) {
@@ -11,7 +11,6 @@ import {
11
11
  replaceTabs,
12
12
  truncateToWidth,
13
13
  } from "../../tools/render-utils";
14
- import { decodeHtmlEntities } from "../scrapers/types";
15
14
  import type { CodeSearchProviderId } from "./types";
16
15
 
17
16
  export interface CodeSearchToolParams {
@@ -42,25 +41,6 @@ export interface CodeSearchRenderDetails {
42
41
  provider: CodeSearchProviderId;
43
42
  }
44
43
 
45
- interface GrepApiHit {
46
- repo: string;
47
- branch: string;
48
- path: string;
49
- contentSnippet?: string;
50
- totalMatches?: string;
51
- }
52
-
53
- interface GrepApiResponse {
54
- totalResults?: number;
55
- hits: GrepApiHit[];
56
- }
57
-
58
- let preferredCodeSearchProvider: CodeSearchProviderId = "grep";
59
-
60
- export function setPreferredCodeSearchProvider(provider: CodeSearchProviderId): void {
61
- preferredCodeSearchProvider = provider;
62
- }
63
-
64
44
  function stringifyExaCodeResponse(payload: unknown): string {
65
45
  if (typeof payload === "string") return payload;
66
46
  if (typeof payload === "number" || typeof payload === "boolean") return String(payload);
@@ -91,161 +71,6 @@ function normalizeExaCodeSearchResponse(
91
71
  };
92
72
  }
93
73
 
94
- function getStringProperty(value: object, key: string): string | undefined {
95
- const candidate = Reflect.get(value, key);
96
- return typeof candidate === "string" ? candidate : undefined;
97
- }
98
-
99
- function getNumberProperty(value: object, key: string): number | undefined {
100
- const candidate = Reflect.get(value, key);
101
- return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : undefined;
102
- }
103
-
104
- function getObjectProperty(value: object, key: string): object | undefined {
105
- const candidate = Reflect.get(value, key);
106
- return typeof candidate === "object" && candidate !== null ? candidate : undefined;
107
- }
108
-
109
- function getArrayProperty(value: object, key: string): unknown[] | undefined {
110
- const candidate = Reflect.get(value, key);
111
- return Array.isArray(candidate) ? candidate : undefined;
112
- }
113
-
114
- function stripHtmlTags(value: string): string {
115
- return value
116
- .replace(/<br\s*\/?>/gi, "")
117
- .replace(/<\/?mark>/gi, "")
118
- .replace(/<span[^>]*>/gi, "")
119
- .replace(/<\/span>/gi, "")
120
- .replace(/<[^>]+>/g, "");
121
- }
122
-
123
- function formatGrepSnippet(snippetHtml: string | undefined): string | undefined {
124
- if (!snippetHtml) return undefined;
125
-
126
- const rowPattern = /<tr[^>]*data-line="(\d+)"[^>]*>[\s\S]*?<pre>([\s\S]*?)<\/pre>[\s\S]*?<\/tr>/gi;
127
- const lines: string[] = [];
128
-
129
- for (const match of snippetHtml.matchAll(rowPattern)) {
130
- const lineNumber = match[1];
131
- const rawCode = match[2] ?? "";
132
- const text = decodeHtmlEntities(stripHtmlTags(rawCode)).trimEnd();
133
- if (text.length === 0) continue;
134
- lines.push(`${lineNumber}: ${text}`);
135
- }
136
-
137
- if (lines.length > 0) {
138
- return lines.join("\n");
139
- }
140
-
141
- const plainText = decodeHtmlEntities(stripHtmlTags(snippetHtml)).replace(/\s+\n/g, "\n").trim();
142
- return plainText.length > 0 ? plainText : undefined;
143
- }
144
-
145
- function parseGrepApiResponse(payload: unknown): GrepApiResponse | null {
146
- if (typeof payload !== "object" || payload === null) return null;
147
-
148
- const hitsObject = getObjectProperty(payload, "hits");
149
- if (!hitsObject) return null;
150
-
151
- const hitValues = getArrayProperty(hitsObject, "hits") ?? [];
152
- const hits: GrepApiHit[] = [];
153
- for (const item of hitValues) {
154
- if (typeof item !== "object" || item === null) continue;
155
- const repo = getStringProperty(item, "repo");
156
- const branch = getStringProperty(item, "branch");
157
- const path = getStringProperty(item, "path");
158
- if (!repo || !branch || !path) continue;
159
-
160
- const content = getObjectProperty(item, "content");
161
- hits.push({
162
- repo,
163
- branch,
164
- path,
165
- contentSnippet: content ? getStringProperty(content, "snippet") : undefined,
166
- totalMatches: getStringProperty(item, "total_matches"),
167
- });
168
- }
169
- if (hitValues.length > 0 && hits.length === 0) return null;
170
-
171
- return {
172
- totalResults: getNumberProperty(hitsObject, "total"),
173
- hits,
174
- };
175
- }
176
-
177
- function buildGrepQuery(params: CodeSearchToolParams): string {
178
- return params.query.trim();
179
- }
180
-
181
- function tokenizeCodeContext(codeContext: string | undefined): string[] {
182
- if (!codeContext) return [];
183
- return codeContext
184
- .toLowerCase()
185
- .split(/[^a-z0-9_./:-]+/i)
186
- .filter(token => token.length >= 2);
187
- }
188
-
189
- function scoreGrepHit(hit: GrepApiHit, contextTokens: string[]): number {
190
- if (contextTokens.length === 0) return 0;
191
- const snippet = formatGrepSnippet(hit.contentSnippet)?.toLowerCase() ?? "";
192
- const repo = hit.repo.toLowerCase();
193
- const path = hit.path.toLowerCase();
194
-
195
- let score = 0;
196
- for (const token of contextTokens) {
197
- if (repo.includes(token)) score += 4;
198
- if (path.includes(token)) score += 3;
199
- if (snippet.includes(token)) score += 2;
200
- }
201
-
202
- return score;
203
- }
204
-
205
- export async function searchCodeWithGrep(params: CodeSearchToolParams): Promise<CodeSearchResponse> {
206
- const query = buildGrepQuery(params);
207
- const url = new URL("https://grep.app/api/search");
208
- url.searchParams.set("q", query);
209
-
210
- const response = await fetch(url, {
211
- headers: {
212
- Accept: "application/json",
213
- Referer: `https://grep.app/search?q=${encodeURIComponent(query)}`,
214
- },
215
- });
216
-
217
- if (!response.ok) {
218
- const message = await response.text();
219
- throw new Error(`grep.app API error (${response.status}): ${message}`);
220
- }
221
-
222
- const payload: unknown = await response.json();
223
- const parsed = parseGrepApiResponse(payload);
224
- if (!parsed) {
225
- throw new Error("grep.app returned an unexpected response shape.");
226
- }
227
-
228
- const contextTokens = tokenizeCodeContext(params.code_context);
229
- const rankedHits = [...parsed.hits].sort(
230
- (left, right) => scoreGrepHit(right, contextTokens) - scoreGrepHit(left, contextTokens),
231
- );
232
-
233
- return {
234
- provider: "grep",
235
- query,
236
- totalResults: parsed.totalResults,
237
- sources: rankedHits.map(hit => ({
238
- title: `${hit.repo}/${hit.path}`,
239
- url: `https://github.com/${hit.repo}/blob/${hit.branch}/${hit.path}`,
240
- repository: hit.repo,
241
- path: hit.path,
242
- branch: hit.branch,
243
- snippet: formatGrepSnippet(hit.contentSnippet),
244
- totalMatches: hit.totalMatches,
245
- })),
246
- };
247
- }
248
-
249
74
  async function searchCodeWithExa(params: CodeSearchToolParams): Promise<CodeSearchResponse> {
250
75
  const exaParams = params.code_context
251
76
  ? { query: params.query, code_context: params.code_context }
@@ -290,8 +115,7 @@ export async function executeCodeSearch(
290
115
  params: CodeSearchToolParams,
291
116
  ): Promise<CustomToolResult<CodeSearchRenderDetails>> {
292
117
  try {
293
- const response =
294
- preferredCodeSearchProvider === "grep" ? await searchCodeWithGrep(params) : await searchCodeWithExa(params);
118
+ const response = await searchCodeWithExa(params);
295
119
 
296
120
  return {
297
121
  content: [{ type: "text", text: formatCodeSearchForLlm(response) }],
@@ -301,7 +125,7 @@ export async function executeCodeSearch(
301
125
  const message = error instanceof Error ? error.message : String(error);
302
126
  return {
303
127
  content: [{ type: "text", text: `Error: ${message}` }],
304
- details: { provider: preferredCodeSearchProvider, error: message },
128
+ details: { provider: "exa", error: message },
305
129
  };
306
130
  }
307
131
  }
@@ -312,7 +136,6 @@ export function renderCodeSearchCall(
312
136
  theme: Theme,
313
137
  ): Component {
314
138
  let text = `${theme.fg("toolTitle", "Code Search")} ${theme.fg("accent", truncateToWidth(args.query, 80))}`;
315
- text += ` ${theme.fg("muted", `provider:${preferredCodeSearchProvider}`)}`;
316
139
  if (args.code_context) {
317
140
  text += ` ${theme.fg("dim", truncateToWidth(args.code_context, 40))}`;
318
141
  }
@@ -4,7 +4,7 @@
4
4
  * Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Tavily, Kagi, Z.AI, and Synthetic
5
5
  * providers with provider-specific parameters exposed conditionally.
6
6
  *
7
- * Code search is also supported via the code_search tool, supports Exa and grep.app.
7
+ * Code search is also supported via the code_search tool.
8
8
  */
9
9
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
10
10
  import { StringEnum } from "@oh-my-pi/pi-ai";
@@ -292,7 +292,6 @@ export function getSearchTools(): CustomTool<any, any>[] {
292
292
  return [webSearchCustomTool, codeSearchTool];
293
293
  }
294
294
 
295
- export { setPreferredCodeSearchProvider } from "./code-search";
296
295
  export { getSearchProvider, setPreferredSearchProvider } from "./provider";
297
296
  export type { SearchProviderId as SearchProvider, SearchResponse } from "./types";
298
- export { isCodeSearchProviderId, isSearchProviderPreference } from "./types";
297
+ export { isSearchProviderPreference } from "./types";
@@ -20,7 +20,7 @@ export type SearchProviderId =
20
20
  | "kagi"
21
21
  | "synthetic";
22
22
 
23
- export type CodeSearchProviderId = "grep" | "exa";
23
+ export type CodeSearchProviderId = "exa";
24
24
 
25
25
  export function isSearchProviderId(value: string): value is SearchProviderId {
26
26
  return [
@@ -44,10 +44,6 @@ export function isSearchProviderPreference(value: string): value is SearchProvid
44
44
  return value === "auto" || isSearchProviderId(value);
45
45
  }
46
46
 
47
- export function isCodeSearchProviderId(value: string): value is CodeSearchProviderId {
48
- return value === "grep" || value === "exa";
49
- }
50
-
51
47
  /** Source returned by search (all providers) */
52
48
  export interface SearchSource {
53
49
  title: string;