@oh-my-pi/pi-coding-agent 13.17.0 → 13.17.5

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 (159) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/package.json +10 -8
  3. package/scripts/format-prompts.ts +3 -3
  4. package/src/cli/plugin-cli.ts +114 -25
  5. package/src/commands/plugin.ts +5 -0
  6. package/src/commit/agentic/prompts/analyze-file.md +1 -1
  7. package/src/commit/agentic/prompts/session-user.md +1 -1
  8. package/src/commit/agentic/prompts/split-confirm.md +1 -1
  9. package/src/commit/agentic/prompts/system.md +1 -1
  10. package/src/commit/git/index.ts +3 -4
  11. package/src/commit/model-selection.ts +1 -19
  12. package/src/commit/prompts/analysis-system.md +1 -1
  13. package/src/commit/prompts/analysis-user.md +1 -1
  14. package/src/commit/prompts/changelog-system.md +1 -1
  15. package/src/commit/prompts/changelog-user.md +1 -1
  16. package/src/commit/prompts/file-observer-system.md +1 -1
  17. package/src/commit/prompts/file-observer-user.md +1 -1
  18. package/src/commit/prompts/reduce-system.md +1 -1
  19. package/src/commit/prompts/reduce-user.md +1 -1
  20. package/src/commit/prompts/summary-retry.md +1 -1
  21. package/src/commit/prompts/summary-system.md +1 -1
  22. package/src/commit/prompts/summary-user.md +1 -1
  23. package/src/commit/prompts/types-description.md +1 -1
  24. package/src/config/model-registry.ts +19 -3
  25. package/src/config/model-resolver.ts +21 -0
  26. package/src/config/settings-schema.ts +16 -0
  27. package/src/discovery/claude-plugins.ts +5 -5
  28. package/src/discovery/helpers.ts +144 -24
  29. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +37 -0
  30. package/src/extensibility/custom-commands/loader.ts +7 -0
  31. package/src/extensibility/plugins/marketplace/fetcher.ts +55 -37
  32. package/src/extensibility/plugins/marketplace/manager.ts +296 -73
  33. package/src/extensibility/plugins/marketplace/registry.ts +15 -0
  34. package/src/extensibility/plugins/marketplace/types.ts +17 -3
  35. package/src/internal-urls/docs-index.generated.ts +2 -1
  36. package/src/main.ts +14 -4
  37. package/src/modes/components/assistant-message.ts +2 -1
  38. package/src/modes/components/plugin-selector.ts +20 -11
  39. package/src/modes/components/tool-execution.ts +2 -3
  40. package/src/modes/controllers/command-controller.ts +19 -7
  41. package/src/modes/controllers/selector-controller.ts +7 -4
  42. package/src/modes/interactive-mode.ts +4 -0
  43. package/src/modes/types.ts +1 -0
  44. package/src/modes/utils/tools-markdown.ts +27 -0
  45. package/src/patch/shared.ts +28 -3
  46. package/src/prompts/agents/designer.md +1 -1
  47. package/src/prompts/agents/explore.md +1 -1
  48. package/src/prompts/agents/frontmatter.md +1 -1
  49. package/src/prompts/agents/init.md +1 -1
  50. package/src/prompts/agents/librarian.md +1 -1
  51. package/src/prompts/agents/oracle.md +1 -1
  52. package/src/prompts/agents/plan.md +1 -1
  53. package/src/prompts/agents/reviewer.md +1 -1
  54. package/src/prompts/agents/task.md +1 -1
  55. package/src/prompts/ci-green-request.md +36 -0
  56. package/src/prompts/compaction/branch-summary-context.md +1 -1
  57. package/src/prompts/compaction/branch-summary-preamble.md +1 -1
  58. package/src/prompts/compaction/branch-summary.md +1 -1
  59. package/src/prompts/compaction/compaction-short-summary.md +1 -1
  60. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  61. package/src/prompts/compaction/compaction-summary.md +1 -1
  62. package/src/prompts/compaction/compaction-turn-prefix.md +1 -1
  63. package/src/prompts/compaction/compaction-update-summary.md +1 -1
  64. package/src/prompts/memories/consolidation.md +1 -1
  65. package/src/prompts/memories/read-path.md +1 -1
  66. package/src/prompts/memories/stage_one_input.md +1 -1
  67. package/src/prompts/memories/stage_one_system.md +1 -1
  68. package/src/prompts/review-request.md +1 -1
  69. package/src/prompts/system/agent-creation-architect.md +1 -1
  70. package/src/prompts/system/agent-creation-user.md +1 -1
  71. package/src/prompts/system/auto-handoff-threshold-focus.md +1 -1
  72. package/src/prompts/system/btw-user.md +1 -1
  73. package/src/prompts/system/commit-message-system.md +1 -1
  74. package/src/prompts/system/custom-system-prompt.md +1 -1
  75. package/src/prompts/system/eager-todo.md +1 -1
  76. package/src/prompts/system/file-operations.md +1 -1
  77. package/src/prompts/system/handoff-document.md +1 -1
  78. package/src/prompts/system/plan-mode-active.md +1 -1
  79. package/src/prompts/system/plan-mode-approved.md +1 -1
  80. package/src/prompts/system/plan-mode-reference.md +1 -1
  81. package/src/prompts/system/plan-mode-subagent.md +1 -1
  82. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  83. package/src/prompts/system/subagent-submit-reminder.md +1 -1
  84. package/src/prompts/system/subagent-system-prompt.md +1 -1
  85. package/src/prompts/system/subagent-user-prompt.md +1 -1
  86. package/src/prompts/system/summarization-system.md +1 -1
  87. package/src/prompts/system/system-prompt.md +1 -1
  88. package/src/prompts/system/title-system.md +1 -1
  89. package/src/prompts/system/ttsr-interrupt.md +1 -1
  90. package/src/prompts/system/web-search.md +1 -1
  91. package/src/prompts/tools/ask.md +1 -1
  92. package/src/prompts/tools/ast-edit.md +1 -1
  93. package/src/prompts/tools/ast-grep.md +1 -1
  94. package/src/prompts/tools/async-result.md +1 -1
  95. package/src/prompts/tools/await.md +1 -1
  96. package/src/prompts/tools/bash.md +1 -1
  97. package/src/prompts/tools/browser.md +1 -1
  98. package/src/prompts/tools/calculator.md +1 -1
  99. package/src/prompts/tools/cancel-job.md +1 -1
  100. package/src/prompts/tools/checkpoint.md +1 -1
  101. package/src/prompts/tools/exit-plan-mode.md +1 -1
  102. package/src/prompts/tools/fetch.md +1 -1
  103. package/src/prompts/tools/find.md +1 -1
  104. package/src/prompts/tools/gemini-image.md +1 -1
  105. package/src/prompts/tools/gh-issue-view.md +11 -0
  106. package/src/prompts/tools/gh-pr-checkout.md +12 -0
  107. package/src/prompts/tools/gh-pr-diff.md +12 -0
  108. package/src/prompts/tools/gh-pr-push.md +11 -0
  109. package/src/prompts/tools/gh-pr-view.md +11 -0
  110. package/src/prompts/tools/gh-repo-view.md +11 -0
  111. package/src/prompts/tools/gh-run-watch.md +12 -0
  112. package/src/prompts/tools/gh-search-issues.md +11 -0
  113. package/src/prompts/tools/gh-search-prs.md +11 -0
  114. package/src/prompts/tools/grep.md +1 -1
  115. package/src/prompts/tools/hashline.md +1 -1
  116. package/src/prompts/tools/inspect-image-system.md +1 -1
  117. package/src/prompts/tools/inspect-image.md +1 -1
  118. package/src/prompts/tools/lsp.md +1 -1
  119. package/src/prompts/tools/patch.md +1 -1
  120. package/src/prompts/tools/python.md +1 -1
  121. package/src/prompts/tools/read.md +6 -3
  122. package/src/prompts/tools/render-mermaid.md +1 -1
  123. package/src/prompts/tools/replace.md +1 -1
  124. package/src/prompts/tools/resolve.md +1 -1
  125. package/src/prompts/tools/rewind.md +1 -1
  126. package/src/prompts/tools/search-tool-bm25.md +1 -1
  127. package/src/prompts/tools/ssh.md +1 -1
  128. package/src/prompts/tools/task-summary.md +1 -1
  129. package/src/prompts/tools/task.md +1 -1
  130. package/src/prompts/tools/todo-write.md +1 -1
  131. package/src/prompts/tools/web-search.md +1 -1
  132. package/src/prompts/tools/write.md +2 -1
  133. package/src/sdk.ts +3 -1
  134. package/src/session/messages.ts +11 -7
  135. package/src/session/session-manager.ts +13 -3
  136. package/src/slash-commands/builtin-registry.ts +109 -37
  137. package/src/slash-commands/marketplace-install-parser.ts +99 -0
  138. package/src/task/discovery.ts +1 -1
  139. package/src/tools/archive-reader.ts +315 -0
  140. package/src/tools/auto-generated-guard.ts +1 -1
  141. package/src/tools/fetch.ts +21 -19
  142. package/src/tools/gh-cli.ts +125 -0
  143. package/src/tools/gh-renderer.ts +305 -0
  144. package/src/tools/gh.ts +2719 -0
  145. package/src/tools/index.ts +22 -0
  146. package/src/tools/read.ts +286 -34
  147. package/src/tools/render-utils.ts +22 -2
  148. package/src/tools/renderers.ts +2 -0
  149. package/src/tools/write.ts +175 -4
  150. package/src/utils/markit.ts +81 -0
  151. package/src/utils/title-generator.ts +4 -8
  152. package/src/utils/tools-manager.ts +1 -6
  153. package/src/web/scrapers/arxiv.ts +3 -3
  154. package/src/web/scrapers/iacr.ts +3 -3
  155. package/src/web/scrapers/utils.ts +6 -34
  156. package/src/web/search/index.ts +1 -36
  157. package/src/web/search/types.ts +0 -2
  158. package/src/prompts/tools/code-search.md +0 -45
  159. package/src/web/search/code-search.ts +0 -208
@@ -31,7 +31,7 @@ import { type ConfigError, ConfigFile } from "../config";
31
31
  import { parseModelString } from "../config/model-resolver";
32
32
  import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
33
33
  import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
34
- import type { Settings } from "./settings";
34
+ import { type Settings, settings } from "./settings";
35
35
 
36
36
  export const kNoAuth = "N/A";
37
37
 
@@ -730,6 +730,14 @@ function normalizeSuppressedSelector(selector: string): string {
730
730
  return `${parsed.provider}/${parsed.id}`;
731
731
  }
732
732
 
733
+ function getDisabledProviderIdsFromSettings(): Set<string> {
734
+ try {
735
+ return new Set(settings.get("disabledProviders"));
736
+ } catch {
737
+ return new Set();
738
+ }
739
+ }
740
+
733
741
  /**
734
742
  * Model registry - loads and manages models, resolves API keys via AuthStorage.
735
743
  */
@@ -1670,11 +1678,19 @@ export class ModelRegistry {
1670
1678
  * This is a fast check that doesn't refresh OAuth tokens.
1671
1679
  */
1672
1680
  getAvailable(): Model<Api>[] {
1673
- return this.#models.filter(m => this.#keylessProviders.has(m.provider) || this.authStorage.hasAuth(m.provider));
1681
+ const disabledProviders = getDisabledProviderIdsFromSettings();
1682
+ return this.#models.filter(
1683
+ m =>
1684
+ !disabledProviders.has(m.provider) &&
1685
+ (this.#keylessProviders.has(m.provider) || this.authStorage.hasAuth(m.provider)),
1686
+ );
1674
1687
  }
1675
1688
 
1676
1689
  getDiscoverableProviders(): string[] {
1677
- return this.#discoverableProviders.map(provider => provider.provider);
1690
+ const disabledProviders = getDisabledProviderIdsFromSettings();
1691
+ return this.#discoverableProviders
1692
+ .filter(provider => !disabledProviders.has(provider.provider))
1693
+ .map(provider => provider.provider);
1678
1694
  }
1679
1695
 
1680
1696
  getProviderDiscoveryState(provider: string): ProviderDiscoveryState | undefined {
@@ -587,6 +587,27 @@ export function resolveModelOverride(
587
587
  return { explicitThinkingLevel: false };
588
588
  }
589
589
 
590
+ /**
591
+ * Resolve a list of role patterns to the first matching model.
592
+ */
593
+ export function resolveRoleSelection(
594
+ roles: readonly string[],
595
+ settings: Settings,
596
+ availableModels: Model<Api>[],
597
+ ): { model: Model<Api>; thinkingLevel?: ThinkingLevel } | undefined {
598
+ const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
599
+ for (const role of roles) {
600
+ const resolved = resolveModelRoleValue(settings.getModelRole(role), availableModels, {
601
+ settings,
602
+ matchPreferences,
603
+ });
604
+ if (resolved.model) {
605
+ return { model: resolved.model, thinkingLevel: resolved.thinkingLevel };
606
+ }
607
+ }
608
+ return undefined;
609
+ }
610
+
590
611
  /**
591
612
  * Resolve model patterns to actual Model objects with optional thinking levels
592
613
  * Format: "pattern:level" where :level is optional
@@ -372,6 +372,12 @@ export const SETTINGS_SCHEMA = {
372
372
  "Maximum width in terminal columns for inline images (default 100). Set to 0 for unlimited (bounded only by terminal width).",
373
373
  },
374
374
 
375
+ "tui.maxInlineImageRows": {
376
+ type: "number",
377
+ default: 20,
378
+ description:
379
+ "Maximum height in terminal rows for inline images (default 20). Set to 0 to use only the viewport-based limit (60% of terminal height).",
380
+ },
375
381
  // Display rendering
376
382
  "display.tabWidth": {
377
383
  type: "number",
@@ -1204,6 +1210,16 @@ export const SETTINGS_SCHEMA = {
1204
1210
  ui: { tab: "tools", label: "Fetch", description: "Enable the fetch tool for URL fetching" },
1205
1211
  },
1206
1212
 
1213
+ "github.enabled": {
1214
+ type: "boolean",
1215
+ default: false,
1216
+ ui: {
1217
+ tab: "tools",
1218
+ label: "GitHub CLI",
1219
+ description: "Enable read-only gh_* tools for GitHub repository, issue, pull request, diff, and search access",
1220
+ },
1221
+ },
1222
+
1207
1223
  "web_search.enabled": {
1208
1224
  type: "boolean",
1209
1225
  default: true,
@@ -36,7 +36,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
36
36
  const items: Skill[] = [];
37
37
  const warnings: string[] = [];
38
38
 
39
- const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home);
39
+ const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
40
40
  warnings.push(...rootWarnings);
41
41
 
42
42
  const results = await Promise.all(
@@ -70,7 +70,7 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
70
70
  const items: SlashCommand[] = [];
71
71
  const warnings: string[] = [];
72
72
 
73
- const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home);
73
+ const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
74
74
  warnings.push(...rootWarnings);
75
75
 
76
76
  const results = await Promise.all(
@@ -108,7 +108,7 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
108
108
  const items: Hook[] = [];
109
109
  const warnings: string[] = [];
110
110
 
111
- const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home);
111
+ const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
112
112
  warnings.push(...rootWarnings);
113
113
 
114
114
  const hookTypes = ["pre", "post"] as const;
@@ -155,7 +155,7 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
155
155
  const items: CustomTool[] = [];
156
156
  const warnings: string[] = [];
157
157
 
158
- const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home);
158
+ const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
159
159
  warnings.push(...rootWarnings);
160
160
 
161
161
  const results = await Promise.all(
@@ -192,7 +192,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
192
192
  const items: MCPServer[] = [];
193
193
  const warnings: string[] = [];
194
194
 
195
- const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home);
195
+ const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
196
196
  warnings.push(...rootWarnings);
197
197
 
198
198
  for (const root of roots) {
@@ -1,8 +1,9 @@
1
1
  import * as fs from "node:fs";
2
+ import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
4
5
  import { FileType, glob } from "@oh-my-pi/pi-natives";
5
- import { CONFIG_DIR_NAME, getConfigDirName, tryParseJson } from "@oh-my-pi/pi-utils";
6
+ import { CONFIG_DIR_NAME, getConfigDirName, getProjectDir, tryParseJson } from "@oh-my-pi/pi-utils";
6
7
  import { readDirEntries, readFile } from "../capability/fs";
7
8
  import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule";
8
9
  import type { Skill, SkillFrontmatter } from "../capability/skill";
@@ -640,19 +641,96 @@ export function parseClaudePluginsRegistry(content: string): ClaudePluginsRegist
640
641
  }
641
642
 
642
643
  /**
643
- * List all installed Claude Code plugin roots from the plugin cache.
644
- * Reads ~/.claude/plugins/installed_plugins.json and resolves plugin paths.
644
+ * Resolve the active project registry path by walking up from `cwd`.
645
+ *
646
+ * Walk order:
647
+ * 1. Walk up from `cwd` looking for the nearest directory containing `.omp/`.
648
+ * The first match returns `<dir>/.omp/plugins/installed_plugins.json`.
649
+ * 2. If no `.omp/` is found, rescan from `cwd` upward looking for `.git`.
650
+ * The git root is used as an anchor: `<gitRoot>/.omp/plugins/installed_plugins.json`.
651
+ * 3. If neither is found, return `null` — no project context is active.
645
652
  *
646
- * Results are cached per home directory to avoid repeated parsing.
653
+ * This is the single source of truth for "active project root" used by install,
654
+ * uninstall, list, upgrade, discovery, and doctor. Deterministic for a given `cwd`.
647
655
  */
656
+ export async function resolveActiveProjectRegistryPath(cwd: string): Promise<string | null> {
657
+ // Pass 1: walk up looking for an existing .omp/ directory (nearest wins).
658
+ // Stop before os.homedir() — ~/.omp/ is the user-level config dir, not a project root.
659
+ const homeDir = os.homedir();
660
+ let dir = path.resolve(cwd);
661
+ while (dir !== homeDir) {
662
+ try {
663
+ const stat = await fs.promises.stat(path.join(dir, getConfigDirName()));
664
+ if (stat.isDirectory()) {
665
+ return path.join(dir, getConfigDirName(), "plugins", "installed_plugins.json");
666
+ }
667
+ } catch {
668
+ // not found at this level — continue up
669
+ }
670
+ const parent = path.dirname(dir);
671
+ if (parent === dir) break; // filesystem root
672
+ dir = parent;
673
+ }
674
+
675
+ // Pass 2: walk up looking for .git as a fallback anchor.
676
+ dir = path.resolve(cwd);
677
+ while (dir !== homeDir) {
678
+ try {
679
+ await fs.promises.stat(path.join(dir, ".git"));
680
+ return path.join(dir, getConfigDirName(), "plugins", "installed_plugins.json");
681
+ } catch {
682
+ // not found at this level — continue up
683
+ }
684
+ const parent = path.dirname(dir);
685
+ if (parent === dir) break; // filesystem root
686
+ dir = parent;
687
+ }
688
+
689
+ return null; // not inside any project
690
+ }
691
+
692
+ /**
693
+ * Like resolveActiveProjectRegistryPath, but falls back to `<cwd>/.omp/plugins/installed_plugins.json`
694
+ * when no project anchor (.omp/ or .git/) is found.
695
+ *
696
+ * Use this when the caller accepts an explicit --scope project so that installing into a freshly
697
+ * bootstrapped directory (no .omp/ or .git/ yet) works: writeInstalledPluginsRegistry auto-creates
698
+ * the directory tree on first write.
699
+ *
700
+ * Returns undefined when cwd is os.homedir() — that path is already the user registry and must
701
+ * never alias as the project registry.
702
+ */
703
+ export async function resolveOrDefaultProjectRegistryPath(cwd: string): Promise<string | undefined> {
704
+ const resolved = await resolveActiveProjectRegistryPath(cwd);
705
+ if (resolved) return resolved;
706
+ // Home directory must not be treated as a project root: the fallback path would alias
707
+ // getInstalledPluginsRegistryPath(), causing MarketplaceManager to load the same file
708
+ // as both user and project registry and producing duplicates / disambiguation errors.
709
+ if (path.resolve(cwd) === os.homedir()) return undefined;
710
+ return path.join(cwd, getConfigDirName(), "plugins", "installed_plugins.json");
711
+ }
712
+
648
713
  const pluginRootsCache = new Map<string, { roots: ClaudePluginRoot[]; warnings: string[] }>();
649
714
 
650
- export async function listClaudePluginRoots(home: string): Promise<{ roots: ClaudePluginRoot[]; warnings: string[] }> {
651
- const cached = pluginRootsCache.get(home);
715
+ /**
716
+ * List all installed Claude Code plugin roots from the plugin cache.
717
+ * Reads ~/.claude/plugins/installed_plugins.json and ~/.omp/plugins/installed_plugins.json,
718
+ * and optionally the nearest project-scoped registry resolved from `cwd`.
719
+ *
720
+ * Results are cached per `home:resolvedProjectPath` key to avoid repeated parsing.
721
+ */
722
+ export async function listClaudePluginRoots(
723
+ home: string,
724
+ cwd?: string,
725
+ ): Promise<{ roots: ClaudePluginRoot[]; warnings: string[] }> {
726
+ const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd) : null;
727
+ const cacheKey = `${home}:${resolvedProjectPath ?? ""}`;
728
+ const cached = pluginRootsCache.get(cacheKey);
652
729
  if (cached) return cached;
653
730
 
654
731
  const roots: ClaudePluginRoot[] = [];
655
732
  const warnings: string[] = [];
733
+ const projectRoots: ClaudePluginRoot[] = [];
656
734
 
657
735
  // ── Claude Code registry ──────────────────────────────────────────────────
658
736
  const registryPath = path.join(home, ".claude", "plugins", "installed_plugins.json");
@@ -746,6 +824,53 @@ export async function listClaudePluginRoots(home: string): Promise<{ roots: Clau
746
824
  }
747
825
  }
748
826
 
827
+ // ── Project-scoped OMP registry ────────────────────────────────────────
828
+ // Loaded from the nearest .omp/plugins/installed_plugins.json relative to cwd.
829
+ // Project entries take precedence over user entries for the same plugin ID.
830
+ if (resolvedProjectPath) {
831
+ const projectContent = await readFile(resolvedProjectPath);
832
+ if (projectContent) {
833
+ const projectRegistry = parseClaudePluginsRegistry(projectContent);
834
+ if (projectRegistry) {
835
+ for (const [pluginId, entries] of Object.entries(projectRegistry.plugins)) {
836
+ if (!Array.isArray(entries) || entries.length === 0) continue;
837
+ const atIndex = pluginId.lastIndexOf("@");
838
+ if (atIndex === -1) {
839
+ warnings.push(`Invalid plugin ID format (missing @marketplace): ${pluginId}`);
840
+ continue;
841
+ }
842
+ const pluginName = pluginId.slice(0, atIndex);
843
+ const marketplace = pluginId.slice(atIndex + 1);
844
+ for (const entry of entries) {
845
+ if (!entry.installPath || typeof entry.installPath !== "string") {
846
+ warnings.push(`Plugin ${pluginId} entry has no installPath`);
847
+ continue;
848
+ }
849
+ if (entry.enabled === false) continue;
850
+ projectRoots.push({
851
+ id: pluginId,
852
+ marketplace,
853
+ plugin: pluginName,
854
+ version: entry.version || "unknown",
855
+ path: entry.installPath,
856
+ scope: "project",
857
+ });
858
+ }
859
+ }
860
+ } else {
861
+ warnings.push(`Failed to parse project plugin registry: ${resolvedProjectPath}`);
862
+ }
863
+ }
864
+ }
865
+
866
+ // Project entries shadow user entries for the same plugin ID.
867
+ if (projectRoots.length > 0) {
868
+ const projectIds = new Set(projectRoots.map(r => r.id));
869
+ const deduped = roots.filter(r => !projectIds.has(r.id));
870
+ roots.length = 0;
871
+ roots.push(...projectRoots, ...deduped);
872
+ }
873
+
749
874
  // Merge --plugin-dir roots (highest precedence) on every fresh load
750
875
  if (injectedPluginDirRoots.length > 0) {
751
876
  const injectedIds = new Set(injectedPluginDirRoots.map(r => r.id));
@@ -755,7 +880,7 @@ export async function listClaudePluginRoots(home: string): Promise<{ roots: Clau
755
880
  }
756
881
 
757
882
  const result = { roots, warnings };
758
- pluginRootsCache.set(home, result);
883
+ pluginRootsCache.set(cacheKey, result);
759
884
  return result;
760
885
  }
761
886
 
@@ -767,7 +892,7 @@ export function clearClaudePluginRootsCache(): void {
767
892
  preloadedPluginRoots = [...injectedPluginDirRoots];
768
893
  // Re-warm preloaded roots asynchronously so sync LSP config reads stay valid
769
894
  if (lastPreloadHome) {
770
- void preloadPluginRoots(lastPreloadHome);
895
+ void preloadPluginRoots(lastPreloadHome, getProjectDir());
771
896
  }
772
897
  }
773
898
 
@@ -784,9 +909,9 @@ let lastPreloadHome: string | undefined;
784
909
  * Call during session initialization, after dir resolution completes
785
910
  * but before any LSP config is read.
786
911
  */
787
- export async function preloadPluginRoots(home: string): Promise<void> {
912
+ export async function preloadPluginRoots(home: string, cwd?: string): Promise<void> {
788
913
  lastPreloadHome = home;
789
- const { roots } = await listClaudePluginRoots(home);
914
+ const { roots } = await listClaudePluginRoots(home, cwd);
790
915
  preloadedPluginRoots = roots;
791
916
  }
792
917
 
@@ -805,10 +930,7 @@ export function getPreloadedPluginRoots(): readonly ClaudePluginRoot[] {
805
930
  * These are prepended to the cache with highest precedence (before OMP/Claude entries).
806
931
  * Must be called before any listClaudePluginRoots() access.
807
932
  */
808
- export async function injectPluginDirRoots(home: string, dirs: string[]): Promise<void> {
809
- // Ensure the base cache is populated first
810
- const { roots, warnings } = await listClaudePluginRoots(home);
811
-
933
+ export async function injectPluginDirRoots(home: string, dirs: string[], cwd?: string): Promise<void> {
812
934
  const injected: ClaudePluginRoot[] = [];
813
935
  for (const dir of dirs) {
814
936
  const resolved = path.resolve(dir);
@@ -828,15 +950,13 @@ export async function injectPluginDirRoots(home: string, dirs: string[]): Promis
828
950
  injected.push(buildPluginDirRoot(resolved, pluginName));
829
951
  }
830
952
 
831
- // --plugin-dir roots have highest precedence: prepend them,
832
- // removing any existing entries with the same plugin ID.
953
+ // Set injected roots BEFORE populating cache so listClaudePluginRoots merges them.
833
954
  injectedPluginDirRoots = injected;
834
-
835
- const injectedIds = new Set(injected.map(r => r.id));
836
- const filtered = roots.filter(r => !injectedIds.has(r.id));
837
- const merged = [...injected, ...filtered];
838
-
839
- // Replace the cache entry
840
- pluginRootsCache.set(home, { roots: merged, warnings });
841
- preloadedPluginRoots = merged;
955
+ lastPreloadHome = home; // ensure cache-clear re-warm fires even when injectPluginDirRoots was the startup path
956
+ // Clear any stale cache entries (populated before injected roots were set).
957
+ pluginRootsCache.clear();
958
+ // Rebuild cache miss triggers fresh load that includes both user+project registries
959
+ // and prepends injectedPluginDirRoots at highest precedence.
960
+ const { roots } = await listClaudePluginRoots(home, cwd);
961
+ preloadedPluginRoots = roots;
842
962
  }
@@ -0,0 +1,37 @@
1
+ import { renderPromptTemplate } from "../../../../config/prompt-templates";
2
+ import type { CustomCommand, CustomCommandAPI } from "../../../../extensibility/custom-commands/types";
3
+ import type { HookCommandContext } from "../../../../extensibility/hooks/types";
4
+ import ciGreenRequestTemplate from "../../../../prompts/ci-green-request.md" with { type: "text" };
5
+
6
+ async function getHeadTag(api: CustomCommandAPI): Promise<string | undefined> {
7
+ const result = await api.exec("git", [
8
+ "for-each-ref",
9
+ "--points-at",
10
+ "HEAD",
11
+ "--sort=-version:refname",
12
+ "--format=%(refname:strip=2)",
13
+ "refs/tags",
14
+ ]);
15
+
16
+ if (result.code !== 0 || result.killed) {
17
+ return undefined;
18
+ }
19
+
20
+ const tag = result.stdout
21
+ .split("\n")
22
+ .map(line => line.trim())
23
+ .find(Boolean);
24
+ return tag || undefined;
25
+ }
26
+
27
+ export class GreenCommand implements CustomCommand {
28
+ name = "green";
29
+ description = "Generate a prompt to iterate on CI failures until the branch is green";
30
+
31
+ constructor(private api: CustomCommandAPI) {}
32
+
33
+ async execute(_args: string[], _ctx: HookCommandContext): Promise<string> {
34
+ const headTag = await getHeadTag(this.api);
35
+ return renderPromptTemplate(ciGreenRequestTemplate, { headTag });
36
+ }
37
+ }
@@ -11,6 +11,7 @@ import { getAgentDir, getProjectDir, isEnoent, logger } from "@oh-my-pi/pi-utils
11
11
  import * as typebox from "@sinclair/typebox";
12
12
  import { getConfigDirs } from "../../config";
13
13
  import { execCommand } from "../../exec/exec";
14
+ import { GreenCommand } from "./bundled/ci-green";
14
15
  import { ReviewCommand } from "./bundled/review";
15
16
  import type {
16
17
  CustomCommand,
@@ -148,6 +149,12 @@ function loadBundledCommands(sharedApi: CustomCommandAPI): LoadedCustomCommand[]
148
149
  const bundled: LoadedCustomCommand[] = [];
149
150
 
150
151
  // Add bundled commands here
152
+ bundled.push({
153
+ path: "bundled:green",
154
+ resolvedPath: "bundled:green",
155
+ command: new GreenCommand(sharedApi),
156
+ source: "bundled",
157
+ });
151
158
  bundled.push({
152
159
  path: "bundled:review",
153
160
  resolvedPath: "bundled:review",
@@ -125,48 +125,66 @@ export function parseMarketplaceCatalog(content: string, filePath: string): Mark
125
125
  assertField(Array.isArray(obj.plugins), "plugins", filePath);
126
126
 
127
127
  const plugins = obj.plugins as unknown[];
128
+ const validPlugins: unknown[] = [];
128
129
  for (let i = 0; i < plugins.length; i++) {
129
- const entry = plugins[i];
130
- assertField(typeof entry === "object" && entry !== null && !Array.isArray(entry), `plugins[${i}]`, filePath);
131
- const p = entry as Record<string, unknown>;
132
- assertField(typeof p.name === "string" && isValidNameSegment(p.name), `plugins[${i}].name`, filePath);
133
- // source can be a string path or a typed object (github/url/git-subdir/npm)
134
- // all typed objects carry a "source" discriminant string field
135
- assertField(
136
- typeof p.source === "string" ||
137
- (typeof p.source === "object" &&
138
- p.source !== null &&
139
- !Array.isArray(p.source) &&
140
- typeof (p.source as Record<string, unknown>).source === "string"),
141
- `plugins[${i}].source`,
142
- filePath,
143
- );
144
- // String sources must be relative paths starting with "./"
145
- if (typeof p.source === "string") {
146
- assertField((p.source as string).startsWith("./"), `plugins[${i}].source (must start with "./")`, filePath);
147
- }
148
- // Validate required fields for typed source variants
149
- if (typeof p.source === "object" && p.source !== null) {
150
- const src = p.source as Record<string, unknown>;
151
- const variant = src.source as string;
152
- if (variant === "github") {
153
- assertField(typeof src.repo === "string" && src.repo.length > 0, `plugins[${i}].source.repo`, filePath);
154
- } else if (variant === "url" || variant === "git-subdir") {
155
- assertField(typeof src.url === "string" && src.url.length > 0, `plugins[${i}].source.url`, filePath);
156
- if (variant === "git-subdir") {
157
- assertField(typeof src.path === "string" && src.path.length > 0, `plugins[${i}].source.path`, filePath);
130
+ try {
131
+ const entry = plugins[i];
132
+ assertField(typeof entry === "object" && entry !== null && !Array.isArray(entry), `plugins[${i}]`, filePath);
133
+ const p = entry as Record<string, unknown>;
134
+ assertField(typeof p.name === "string" && isValidNameSegment(p.name), `plugins[${i}].name`, filePath);
135
+ // source can be a string path or a typed object (github/url/git-subdir/npm)
136
+ // all typed objects carry a "source" discriminant string field
137
+ assertField(
138
+ typeof p.source === "string" ||
139
+ (typeof p.source === "object" &&
140
+ p.source !== null &&
141
+ !Array.isArray(p.source) &&
142
+ typeof (p.source as Record<string, unknown>).source === "string"),
143
+ `plugins[${i}].source`,
144
+ filePath,
145
+ );
146
+ // String sources must be relative paths starting with "./"
147
+ if (typeof p.source === "string") {
148
+ assertField((p.source as string).startsWith("./"), `plugins[${i}].source (must start with "./")`, filePath);
149
+ }
150
+ // Validate required fields for typed source variants
151
+ if (typeof p.source === "object" && p.source !== null) {
152
+ const src = p.source as Record<string, unknown>;
153
+ const variant = src.source as string;
154
+ if (variant === "github") {
155
+ assertField(typeof src.repo === "string" && src.repo.length > 0, `plugins[${i}].source.repo`, filePath);
156
+ } else if (variant === "url" || variant === "git-subdir") {
157
+ assertField(typeof src.url === "string" && src.url.length > 0, `plugins[${i}].source.url`, filePath);
158
+ if (variant === "git-subdir") {
159
+ assertField(
160
+ typeof src.path === "string" && src.path.length > 0,
161
+ `plugins[${i}].source.path`,
162
+ filePath,
163
+ );
164
+ }
165
+ } else if (variant === "npm") {
166
+ assertField(
167
+ typeof src.package === "string" && src.package.length > 0,
168
+ `plugins[${i}].source.package`,
169
+ filePath,
170
+ );
171
+ } else {
172
+ assertField(false, `plugins[${i}].source.source (unknown variant: "${variant}")`, filePath);
158
173
  }
159
- } else if (variant === "npm") {
160
- assertField(
161
- typeof src.package === "string" && src.package.length > 0,
162
- `plugins[${i}].source.package`,
163
- filePath,
164
- );
165
- } else {
166
- assertField(false, `plugins[${i}].source.source (unknown variant: "${variant}")`, filePath);
167
174
  }
175
+ validPlugins.push(entry);
176
+ } catch (err) {
177
+ // Warn and skip invalid plugin entries instead of failing the entire catalog.
178
+ // This lets the rest of the marketplace load even if one entry has a bad name/source.
179
+ const name =
180
+ typeof plugins[i] === "object" && plugins[i] !== null
181
+ ? ((plugins[i] as Record<string, unknown>).name ?? `[${i}]`)
182
+ : `[${i}]`;
183
+ logger.warn(`Skipping invalid plugin ${name}: ${(err as Error).message}`);
168
184
  }
169
185
  }
186
+ // Replace the plugins array with only valid entries
187
+ obj.plugins = validPlugins;
170
188
 
171
189
  // Extra fields are preserved — cast through unknown for type safety
172
190
  return obj as unknown as MarketplaceCatalog;