@oh-my-pi/pi-coding-agent 16.3.11 → 16.3.13
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/CHANGELOG.md +65 -0
- package/dist/cli.js +3176 -3087
- package/dist/types/advisor/runtime.d.ts +11 -0
- package/dist/types/config/keybindings.d.ts +9 -4
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/settings-schema.d.ts +6 -0
- package/dist/types/config/settings.d.ts +3 -1
- package/dist/types/discovery/helpers.d.ts +9 -0
- package/dist/types/exec/bash-executor.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +11 -2
- package/dist/types/extensibility/shared-events.d.ts +2 -2
- package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +1 -0
- package/dist/types/internal-urls/registry-helpers.d.ts +7 -5
- package/dist/types/mnemopi/state.d.ts +7 -3
- package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -1
- package/dist/types/modes/components/read-tool-group.d.ts +1 -0
- package/dist/types/modes/github-ref-autocomplete.d.ts +35 -0
- package/dist/types/modes/interactive-mode.d.ts +3 -1
- package/dist/types/modes/rpc/rpc-client.d.ts +11 -5
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/modes/utils/context-usage.d.ts +0 -12
- package/dist/types/modes/workflow.d.ts +5 -1
- package/dist/types/session/agent-session.d.ts +8 -4
- package/dist/types/system-prompt.d.ts +1 -1
- package/dist/types/tools/bash-interactive.d.ts +1 -1
- package/dist/types/tools/bash-skill-urls.d.ts +1 -0
- package/dist/types/tools/bash.d.ts +2 -1
- package/dist/types/tools/browser/launch.d.ts +1 -0
- package/dist/types/tools/grep.d.ts +2 -0
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/path-utils.d.ts +24 -0
- package/dist/types/tools/read.d.ts +3 -0
- package/dist/types/tools/renderers.d.ts +12 -5
- package/dist/types/tools/ssh.d.ts +4 -1
- package/dist/types/tools/write.d.ts +1 -0
- package/dist/types/utils/local-date.d.ts +2 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +145 -0
- package/src/advisor/runtime.ts +19 -0
- package/src/config/api-key-resolver.ts +7 -2
- package/src/config/keybindings.ts +62 -10
- package/src/config/model-registry.ts +94 -20
- package/src/config/settings-schema.ts +11 -1
- package/src/config/settings.ts +59 -21
- package/src/discovery/builtin.ts +2 -1
- package/src/discovery/claude-plugins.ts +167 -46
- package/src/discovery/helpers.ts +16 -1
- package/src/edit/renderer.ts +20 -6
- package/src/eval/js/worker-core.ts +163 -6
- package/src/exec/bash-executor.ts +14 -9
- package/src/extensibility/extensions/runner.ts +1 -0
- package/src/extensibility/extensions/types.ts +13 -2
- package/src/extensibility/plugins/legacy-pi-compat.ts +6 -2
- package/src/extensibility/plugins/marketplace/fetcher.ts +15 -14
- package/src/extensibility/shared-events.ts +2 -2
- package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +68 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/registry-helpers.ts +9 -6
- package/src/mnemopi/state.ts +19 -5
- package/src/modes/acp/acp-agent.ts +69 -8
- package/src/modes/acp/acp-event-mapper.ts +1 -1
- package/src/modes/components/model-selector.ts +30 -6
- package/src/modes/components/read-tool-group.ts +5 -1
- package/src/modes/components/settings-defs.ts +1 -1
- package/src/modes/components/status-line/component.ts +14 -2
- package/src/modes/components/tool-execution.ts +28 -24
- package/src/modes/controllers/command-controller.ts +13 -23
- package/src/modes/controllers/event-controller.ts +12 -12
- package/src/modes/controllers/extension-ui-controller.test.ts +16 -0
- package/src/modes/controllers/extension-ui-controller.ts +7 -35
- package/src/modes/controllers/input-controller.ts +23 -57
- package/src/modes/controllers/mcp-command-controller.ts +10 -9
- package/src/modes/controllers/selector-controller.ts +16 -5
- package/src/modes/github-ref-autocomplete.ts +75 -0
- package/src/modes/interactive-mode.ts +97 -12
- package/src/modes/prompt-action-autocomplete.ts +35 -0
- package/src/modes/rpc/rpc-client.ts +42 -13
- package/src/modes/rpc/rpc-mode.ts +21 -19
- package/src/modes/types.ts +3 -0
- package/src/modes/utils/context-usage.ts +58 -5
- package/src/modes/utils/hotkeys-markdown.ts +2 -1
- package/src/modes/utils/ui-helpers.ts +2 -2
- package/src/modes/workflow.ts +14 -8
- package/src/prompts/agents/plan.md +0 -1
- package/src/prompts/agents/reviewer.md +0 -1
- package/src/prompts/system/plan-mode-active.md +5 -2
- package/src/prompts/system/system-prompt.md +1 -2
- package/src/prompts/system/workflow-notice.md +69 -50
- package/src/prompts/tools/bash.md +18 -7
- package/src/prompts/tools/grep.md +2 -1
- package/src/prompts/tools/memory-edit.md +2 -0
- package/src/prompts/tools/read.md +4 -3
- package/src/sdk.ts +11 -0
- package/src/session/agent-session.ts +136 -19
- package/src/system-prompt.ts +3 -2
- package/src/tools/bash-interactive.ts +1 -1
- package/src/tools/bash-skill-urls.ts +39 -7
- package/src/tools/bash.ts +69 -39
- package/src/tools/browser/launch.ts +31 -4
- package/src/tools/grep.ts +105 -21
- package/src/tools/image-gen.ts +1 -1
- package/src/tools/index.ts +11 -0
- package/src/tools/memory-edit.ts +3 -1
- package/src/tools/path-utils.ts +46 -1
- package/src/tools/read.ts +135 -57
- package/src/tools/renderers.ts +13 -5
- package/src/tools/ssh.ts +10 -3
- package/src/tools/tts.ts +1 -1
- package/src/tools/write.ts +26 -0
- package/src/utils/local-date.ts +7 -0
- package/src/utils/open.ts +36 -10
package/src/config/settings.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
getProjectDir,
|
|
23
23
|
isEnoent,
|
|
24
24
|
logger,
|
|
25
|
+
MAIN_CONFIG_FILENAMES,
|
|
25
26
|
procmgr,
|
|
26
27
|
setWorktreesDir,
|
|
27
28
|
} from "@oh-my-pi/pi-utils";
|
|
@@ -60,7 +61,7 @@ export interface RawSettings {
|
|
|
60
61
|
export interface SettingsOptions {
|
|
61
62
|
/** Current working directory for project settings discovery */
|
|
62
63
|
cwd?: string;
|
|
63
|
-
/** Agent directory for config.yml storage */
|
|
64
|
+
/** Agent directory for config.yml/config.yaml storage */
|
|
64
65
|
agentDir?: string;
|
|
65
66
|
/** Don't persist to disk (for tests) */
|
|
66
67
|
inMemory?: boolean;
|
|
@@ -234,7 +235,7 @@ export class Settings {
|
|
|
234
235
|
#storage: AgentStorage | null = null;
|
|
235
236
|
|
|
236
237
|
#configFiles: string[] = [];
|
|
237
|
-
/** Global settings from config.yml */
|
|
238
|
+
/** Global settings from config.yml/config.yaml */
|
|
238
239
|
#global: RawSettings = {};
|
|
239
240
|
/** Project settings from .claude/settings.yml etc */
|
|
240
241
|
#project: RawSettings = {};
|
|
@@ -264,7 +265,7 @@ export class Settings {
|
|
|
264
265
|
private constructor(options: SettingsOptions = {}) {
|
|
265
266
|
this.#cwd = path.normalize(options.cwd ?? getProjectDir());
|
|
266
267
|
this.#agentDir = path.normalize(options.agentDir ?? getAgentDir());
|
|
267
|
-
this.#configPath = options.inMemory ? null : path.join(this.#agentDir,
|
|
268
|
+
this.#configPath = options.inMemory ? null : path.join(this.#agentDir, MAIN_CONFIG_FILENAMES[0]);
|
|
268
269
|
this.#configFiles = options.configFiles?.map(file => path.resolve(this.#cwd, expandTilde(file))) ?? [];
|
|
269
270
|
this.#persist = !options.inMemory && options.readOnly !== true;
|
|
270
271
|
|
|
@@ -429,6 +430,9 @@ export class Settings {
|
|
|
429
430
|
if (path === "statusLine.sessionAccent") {
|
|
430
431
|
statusLineSessionAccentSignal.fire();
|
|
431
432
|
}
|
|
433
|
+
if (path === "modelRoles") {
|
|
434
|
+
modelRolesSignal.fire();
|
|
435
|
+
}
|
|
432
436
|
}
|
|
433
437
|
|
|
434
438
|
/**
|
|
@@ -455,6 +459,7 @@ export class Settings {
|
|
|
455
459
|
inMemory: !this.#persist,
|
|
456
460
|
});
|
|
457
461
|
cloned.#storage = this.#storage;
|
|
462
|
+
cloned.#configPath = this.#configPath;
|
|
458
463
|
cloned.#global = structuredClone(this.#global);
|
|
459
464
|
cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project);
|
|
460
465
|
cloned.#configFiles = [...this.#configFiles];
|
|
@@ -480,11 +485,13 @@ export class Settings {
|
|
|
480
485
|
async reloadForCwd(cwd: string): Promise<void> {
|
|
481
486
|
const normalized = path.normalize(cwd);
|
|
482
487
|
if (normalized === this.#cwd) return;
|
|
488
|
+
const prevModelRoles = this.get("modelRoles");
|
|
483
489
|
this.#cwd = normalized;
|
|
484
490
|
if (this.#persist) {
|
|
485
491
|
this.#project = await this.#loadProjectSettings();
|
|
486
492
|
}
|
|
487
493
|
this.#rebuildMerged();
|
|
494
|
+
this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prevModelRoles);
|
|
488
495
|
this.#fireAllHooks();
|
|
489
496
|
}
|
|
490
497
|
|
|
@@ -671,16 +678,22 @@ export class Settings {
|
|
|
671
678
|
|
|
672
679
|
async #load(): Promise<Settings> {
|
|
673
680
|
// Project settings load (loadCapability scans cwd) is independent of the
|
|
674
|
-
// persist chain (storage open → legacy migration → global config
|
|
675
|
-
//
|
|
676
|
-
// persist steps remain sequential:
|
|
677
|
-
//
|
|
681
|
+
// persist chain (storage open → legacy migration → global config read), so
|
|
682
|
+
// kick it off first and await after the persist chain completes. The
|
|
683
|
+
// persist steps remain sequential: existing config discovery decides
|
|
684
|
+
// whether migration may write config.yml before the global config is read;
|
|
685
|
+
// migration's db fallback needs #storage opened.
|
|
678
686
|
const projectPromise = this.#loadProjectSettings();
|
|
679
687
|
|
|
680
688
|
if (this.#persist) {
|
|
681
689
|
this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
|
|
682
|
-
await this.#
|
|
683
|
-
|
|
690
|
+
const existingConfig = await this.#loadExistingMainYaml();
|
|
691
|
+
if (existingConfig) {
|
|
692
|
+
this.#global = existingConfig;
|
|
693
|
+
} else {
|
|
694
|
+
await this.#migrateFromLegacy();
|
|
695
|
+
this.#global = await this.#loadYaml(this.#configPath!);
|
|
696
|
+
}
|
|
684
697
|
await this.#seedLastChangelogVersionMarker();
|
|
685
698
|
}
|
|
686
699
|
|
|
@@ -696,8 +709,9 @@ export class Settings {
|
|
|
696
709
|
async #loadReadOnly(): Promise<Settings> {
|
|
697
710
|
const projectPromise = this.#loadProjectSettings();
|
|
698
711
|
|
|
699
|
-
|
|
700
|
-
|
|
712
|
+
const existingConfig = await this.#loadExistingMainYaml();
|
|
713
|
+
if (existingConfig) {
|
|
714
|
+
this.#global = existingConfig;
|
|
701
715
|
}
|
|
702
716
|
|
|
703
717
|
this.#project = await projectPromise;
|
|
@@ -707,20 +721,46 @@ export class Settings {
|
|
|
707
721
|
}
|
|
708
722
|
|
|
709
723
|
async #loadYaml(filePath: string): Promise<RawSettings> {
|
|
724
|
+
const loaded = await this.#loadYamlIfPresent(filePath);
|
|
725
|
+
return loaded ?? {};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async #loadYamlIfPresent(filePath: string): Promise<RawSettings | null> {
|
|
729
|
+
let content: string;
|
|
730
|
+
try {
|
|
731
|
+
content = await Bun.file(filePath).text();
|
|
732
|
+
} catch (error) {
|
|
733
|
+
if (isEnoent(error)) return null;
|
|
734
|
+
logger.warn("Settings: failed to load", { path: filePath, error: String(error) });
|
|
735
|
+
return {};
|
|
736
|
+
}
|
|
737
|
+
|
|
710
738
|
try {
|
|
711
|
-
const content = await Bun.file(filePath).text();
|
|
712
739
|
const parsed = YAML.parse(content);
|
|
713
740
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
714
741
|
return {};
|
|
715
742
|
}
|
|
716
743
|
return this.#migrateRawSettings(parsed as RawSettings);
|
|
717
744
|
} catch (error) {
|
|
718
|
-
if (isEnoent(error)) return {};
|
|
719
745
|
logger.warn("Settings: failed to load", { path: filePath, error: String(error) });
|
|
720
746
|
return {};
|
|
721
747
|
}
|
|
722
748
|
}
|
|
723
749
|
|
|
750
|
+
async #loadExistingMainYaml(): Promise<RawSettings | null> {
|
|
751
|
+
if (!this.#configPath) return null;
|
|
752
|
+
for (const filename of MAIN_CONFIG_FILENAMES) {
|
|
753
|
+
const configPath = path.join(this.#agentDir, filename);
|
|
754
|
+
const loaded = await this.#loadYamlIfPresent(configPath);
|
|
755
|
+
if (loaded) {
|
|
756
|
+
this.#configPath = configPath;
|
|
757
|
+
return loaded;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
this.#configPath = path.join(this.#agentDir, MAIN_CONFIG_FILENAMES[0]);
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
|
|
724
764
|
async #loadProjectSettings(): Promise<RawSettings> {
|
|
725
765
|
try {
|
|
726
766
|
const result = await loadCapability(settingsCapability.id, { cwd: this.#cwd });
|
|
@@ -776,14 +816,6 @@ export class Settings {
|
|
|
776
816
|
async #migrateFromLegacy(): Promise<void> {
|
|
777
817
|
if (!this.#configPath) return;
|
|
778
818
|
|
|
779
|
-
// Check if config.yml already exists
|
|
780
|
-
try {
|
|
781
|
-
await Bun.file(this.#configPath).text();
|
|
782
|
-
return; // Already exists, no migration needed
|
|
783
|
-
} catch (err) {
|
|
784
|
-
if (!isEnoent(err)) return;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
819
|
let settings: RawSettings = {};
|
|
788
820
|
let migrated = false;
|
|
789
821
|
|
|
@@ -1477,6 +1509,12 @@ const appendOnlyModeSignal = new SettingSignal<[value: string]>("provider.append
|
|
|
1477
1509
|
*/
|
|
1478
1510
|
export const onAppendOnlyModeChanged = (cb: (value: string) => void) => appendOnlyModeSignal.on(cb);
|
|
1479
1511
|
|
|
1512
|
+
/** Fires when any model role changes at runtime. */
|
|
1513
|
+
const modelRolesSignal = new SettingSignal("modelRoles");
|
|
1514
|
+
|
|
1515
|
+
/** Subscribe to model role changes. Returns an unsubscribe function. */
|
|
1516
|
+
export const onModelRolesChanged: (cb: () => void) => () => void = modelRolesSignal.on.bind(modelRolesSignal);
|
|
1517
|
+
|
|
1480
1518
|
/** Fires when `statusLine.sessionAccent` changes at runtime. */
|
|
1481
1519
|
const statusLineSessionAccentSignal = new SettingSignal("statusLine.sessionAccent");
|
|
1482
1520
|
|
package/src/discovery/builtin.ts
CHANGED
|
@@ -401,7 +401,8 @@ async function loadStickyRulesFile(filePath: string, level: "user" | "project"):
|
|
|
401
401
|
const content = await readFile(filePath);
|
|
402
402
|
if (!content) return null;
|
|
403
403
|
const source = createSourceMeta(PROVIDER_ID, filePath, level);
|
|
404
|
-
const
|
|
404
|
+
const ruleName = level === "project" ? "RULES@project" : "RULES";
|
|
405
|
+
const rule = buildRuleFromMarkdown("RULES.md", content, filePath, source, { ruleName });
|
|
405
406
|
// Force alwaysApply regardless of frontmatter — the whole point of RULES.md
|
|
406
407
|
// is to be reattached every turn.
|
|
407
408
|
return { ...rule, alwaysApply: true };
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Loads configuration from ~/.claude/plugins/cache/ based on installed_plugins.json registry.
|
|
5
5
|
* Priority: 70 (below claude.ts at 80, so user overrides in .claude/ take precedence)
|
|
6
6
|
*/
|
|
7
|
+
import * as fs from "node:fs/promises";
|
|
7
8
|
import * as path from "node:path";
|
|
8
9
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
9
10
|
import { registerProvider } from "../capability";
|
|
@@ -30,14 +31,14 @@ const DISPLAY_NAME = "Claude Code Marketplace";
|
|
|
30
31
|
const PRIORITY = 70; // Below claude.ts (80) so user .claude/ overrides win
|
|
31
32
|
|
|
32
33
|
interface ClaudePluginManifest {
|
|
33
|
-
skills?: string;
|
|
34
|
-
"slash-commands"?: string;
|
|
35
|
-
commands?: string;
|
|
34
|
+
skills?: string | string[];
|
|
35
|
+
"slash-commands"?: string | string[];
|
|
36
|
+
commands?: string | string[];
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
interface ResolvedPluginDir {
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
dirs: string[];
|
|
41
|
+
warnings: string[];
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
async function readPluginManifest(root: ClaudePluginRoot): Promise<ClaudePluginManifest | null> {
|
|
@@ -54,43 +55,116 @@ async function readPluginManifest(root: ClaudePluginRoot): Promise<ClaudePluginM
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
59
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function skillsManifestReplacesFallback(root: ClaudePluginRoot): Promise<boolean> {
|
|
63
|
+
const raw = await readFile(path.join(root.path, "marketplace.json"));
|
|
64
|
+
if (raw === null) return false;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const parsed: unknown = JSON.parse(raw);
|
|
68
|
+
if (!isRecord(parsed)) return false;
|
|
69
|
+
const plugins = parsed.plugins;
|
|
70
|
+
return (
|
|
71
|
+
Array.isArray(plugins) &&
|
|
72
|
+
plugins.some(entry => isRecord(entry) && entry.name === root.plugin && entry.source === "./")
|
|
73
|
+
);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
57
79
|
function isWithinPluginRoot(rootPath: string, targetPath: string): boolean {
|
|
58
80
|
const relative = path.relative(rootPath, targetPath);
|
|
59
81
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
60
82
|
}
|
|
61
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a manifest-declared directory field to absolute paths within the
|
|
86
|
+
* plugin root.
|
|
87
|
+
*
|
|
88
|
+
* Manifest path fields may be `string` or `string[]`
|
|
89
|
+
* (https://code.claude.com/docs/en/plugins-reference#path-behavior-rules);
|
|
90
|
+
* both shapes are normalized here. The first `manifestKeys` entry that
|
|
91
|
+
* supplies at least one non-empty path wins (later keys are ignored — used for
|
|
92
|
+
* the `commands` > `slash-commands` legacy fallback).
|
|
93
|
+
*
|
|
94
|
+
* `fallback` is the default subdirectory (e.g. `skills/`, `commands/`) and
|
|
95
|
+
* `includeFallback` controls the Claude-documented merge semantic per field:
|
|
96
|
+
*
|
|
97
|
+
* - `skills` **adds to** the default: `fallback` is always scanned, and any
|
|
98
|
+
* manifest entries load alongside it. Callers pass `includeFallback: true`.
|
|
99
|
+
* - `commands` / `slash-commands` **replace** the default: an explicit
|
|
100
|
+
* manifest key means the default `commands/` directory is not scanned.
|
|
101
|
+
* Callers pass `includeFallback: false` (the manifest itself may still
|
|
102
|
+
* list `./commands` explicitly to keep it).
|
|
103
|
+
*
|
|
104
|
+
* When no matching key is set, the fallback is used regardless. Entries that
|
|
105
|
+
* resolve outside the plugin root are dropped with a warning so misconfigured
|
|
106
|
+
* manifests remain observable and cannot escape via traversal.
|
|
107
|
+
*/
|
|
62
108
|
async function resolvePluginDir(
|
|
63
109
|
root: ClaudePluginRoot,
|
|
64
110
|
manifestKeys: ReadonlyArray<keyof ClaudePluginManifest>,
|
|
65
111
|
fallback: string,
|
|
112
|
+
includeFallback: boolean,
|
|
66
113
|
): Promise<ResolvedPluginDir> {
|
|
67
114
|
const manifest = await readPluginManifest(root);
|
|
68
115
|
const fallbackDir = path.join(root.path, fallback);
|
|
69
116
|
|
|
70
|
-
let configured: string | undefined;
|
|
117
|
+
let configured: string[] | undefined;
|
|
71
118
|
let matchedKey: keyof ClaudePluginManifest | undefined;
|
|
72
119
|
for (const key of manifestKeys) {
|
|
73
120
|
const val = manifest?.[key];
|
|
74
|
-
|
|
75
|
-
|
|
121
|
+
const candidates: string[] = [];
|
|
122
|
+
if (typeof val === "string") {
|
|
123
|
+
const trimmed = val.trim();
|
|
124
|
+
if (trimmed) candidates.push(trimmed);
|
|
125
|
+
} else if (Array.isArray(val)) {
|
|
126
|
+
for (const entry of val) {
|
|
127
|
+
if (typeof entry !== "string") continue;
|
|
128
|
+
const trimmed = entry.trim();
|
|
129
|
+
if (trimmed) candidates.push(trimmed);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (candidates.length > 0) {
|
|
133
|
+
configured = candidates;
|
|
76
134
|
matchedKey = key;
|
|
77
135
|
break;
|
|
78
136
|
}
|
|
79
137
|
}
|
|
80
138
|
|
|
81
139
|
if (configured === undefined) {
|
|
82
|
-
return {
|
|
140
|
+
return { dirs: [fallbackDir], warnings: [] };
|
|
83
141
|
}
|
|
84
142
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
143
|
+
// Dedup preserves order: default entry (when included) first, then declared
|
|
144
|
+
// entries in manifest order. Deduping the paths themselves means a plugin
|
|
145
|
+
// author can still list `./commands` explicitly when they want the default
|
|
146
|
+
// alongside extras without producing double-loads.
|
|
147
|
+
const seen = new Set<string>();
|
|
148
|
+
const dirs: string[] = [];
|
|
149
|
+
const warnings: string[] = [];
|
|
150
|
+
if (includeFallback) {
|
|
151
|
+
seen.add(fallbackDir);
|
|
152
|
+
dirs.push(fallbackDir);
|
|
153
|
+
}
|
|
154
|
+
for (const entry of configured) {
|
|
155
|
+
const resolved = path.resolve(root.path, entry);
|
|
156
|
+
if (!isWithinPluginRoot(root.path, resolved)) {
|
|
157
|
+
warnings.push(
|
|
158
|
+
`[claude-plugins] Ignoring ${String(matchedKey)} path outside plugin root for ${root.id}: ${entry}`,
|
|
159
|
+
);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (seen.has(resolved)) continue;
|
|
163
|
+
seen.add(resolved);
|
|
164
|
+
dirs.push(resolved);
|
|
88
165
|
}
|
|
89
166
|
|
|
90
|
-
return {
|
|
91
|
-
dir: fallbackDir,
|
|
92
|
-
warning: `[claude-plugins] Ignoring ${String(matchedKey)} path outside plugin root for ${root.id}: ${configured}`,
|
|
93
|
-
};
|
|
167
|
+
return { dirs, warnings };
|
|
94
168
|
}
|
|
95
169
|
|
|
96
170
|
// =============================================================================
|
|
@@ -104,24 +178,37 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
|
104
178
|
warnings.push(...rootWarnings);
|
|
105
179
|
const results = await Promise.all(
|
|
106
180
|
roots.map(async root => {
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
181
|
+
const includeFallback = !(await skillsManifestReplacesFallback(root));
|
|
182
|
+
const { dirs: skillsDirs, warnings: resolveWarnings } = await resolvePluginDir(
|
|
183
|
+
root,
|
|
184
|
+
["skills"],
|
|
185
|
+
"skills",
|
|
186
|
+
includeFallback,
|
|
187
|
+
);
|
|
188
|
+
const scanResults = await Promise.all(
|
|
189
|
+
skillsDirs.map(dir =>
|
|
190
|
+
scanSkillsFromDir(ctx, {
|
|
191
|
+
dir,
|
|
192
|
+
providerId: PROVIDER_ID,
|
|
193
|
+
level: root.scope,
|
|
194
|
+
includeSelf: true,
|
|
195
|
+
}),
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
return { scanResults, resolveWarnings };
|
|
114
199
|
}),
|
|
115
200
|
);
|
|
116
|
-
for (const {
|
|
117
|
-
|
|
201
|
+
for (const { scanResults, resolveWarnings } of results) {
|
|
202
|
+
warnings.push(...resolveWarnings);
|
|
118
203
|
// Intentionally do NOT prefix skill names with `root.plugin`.
|
|
119
204
|
// The `plugin:name` format breaks skill:// URL parsing (colons are
|
|
120
205
|
// ambiguous with port separators) and is unintuitive for callers.
|
|
121
206
|
// Dedup-by-key in the capability layer already handles name collisions
|
|
122
207
|
// across providers using priority ordering.
|
|
123
|
-
|
|
124
|
-
|
|
208
|
+
for (const result of scanResults) {
|
|
209
|
+
items.push(...result.items);
|
|
210
|
+
if (result.warnings) warnings.push(...result.warnings);
|
|
211
|
+
}
|
|
125
212
|
}
|
|
126
213
|
return { items, warnings };
|
|
127
214
|
}
|
|
@@ -139,28 +226,62 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
|
|
|
139
226
|
|
|
140
227
|
const results = await Promise.all(
|
|
141
228
|
roots.map(async root => {
|
|
142
|
-
const {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
229
|
+
const { dirs: commandsDirs, warnings: resolveWarnings } = await resolvePluginDir(
|
|
230
|
+
root,
|
|
231
|
+
["commands", "slash-commands"],
|
|
232
|
+
"commands",
|
|
233
|
+
false,
|
|
234
|
+
);
|
|
235
|
+
const commandResults = await Promise.all(
|
|
236
|
+
commandsDirs.map(async dir => {
|
|
237
|
+
try {
|
|
238
|
+
const stats = await fs.stat(dir);
|
|
239
|
+
if (stats.isFile()) {
|
|
240
|
+
if (path.extname(dir) !== ".md") return { items: [], warnings: [] };
|
|
241
|
+
const content = await readFile(dir);
|
|
242
|
+
if (content === null) return { items: [], warnings: [`Failed to read file: ${dir}`] };
|
|
243
|
+
const cmdName = path.basename(dir).replace(/\.md$/, "");
|
|
244
|
+
return {
|
|
245
|
+
items: [
|
|
246
|
+
{
|
|
247
|
+
name: root.plugin ? `${root.plugin}:${cmdName}` : cmdName,
|
|
248
|
+
path: dir,
|
|
249
|
+
content,
|
|
250
|
+
level: root.scope,
|
|
251
|
+
_source: createSourceMeta(PROVIDER_ID, dir, root.scope),
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
warnings: [],
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
// Missing entries behave like missing directories: no items, no warning.
|
|
259
|
+
}
|
|
260
|
+
return loadFilesFromDir<SlashCommand>(ctx, dir, PROVIDER_ID, root.scope, {
|
|
261
|
+
extensions: ["md"],
|
|
262
|
+
transform: (name, content, filePath, source) => {
|
|
263
|
+
const cmdName = name.replace(/\.md$/, "");
|
|
264
|
+
return {
|
|
265
|
+
name: root.plugin ? `${root.plugin}:${cmdName}` : cmdName,
|
|
266
|
+
path: filePath,
|
|
267
|
+
content,
|
|
268
|
+
level: root.scope,
|
|
269
|
+
_source: source,
|
|
270
|
+
};
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
return { commandResults, resolveWarnings };
|
|
157
276
|
}),
|
|
158
277
|
);
|
|
159
278
|
|
|
160
|
-
for (const {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
279
|
+
for (const { commandResults, resolveWarnings } of results) {
|
|
280
|
+
warnings.push(...resolveWarnings);
|
|
281
|
+
for (const commandResult of commandResults) {
|
|
282
|
+
items.push(...commandResult.items);
|
|
283
|
+
if (commandResult.warnings) warnings.push(...commandResult.warnings);
|
|
284
|
+
}
|
|
164
285
|
}
|
|
165
286
|
|
|
166
287
|
return { items, warnings };
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -312,6 +312,15 @@ export interface ScanSkillsFromDirOptions {
|
|
|
312
312
|
providerId: string;
|
|
313
313
|
level: "user" | "project";
|
|
314
314
|
requireDescription?: boolean;
|
|
315
|
+
/**
|
|
316
|
+
* When true, treat a `SKILL.md` sitting directly under `dir` as a single skill in addition to
|
|
317
|
+
* scanning `<dir>/<name>/SKILL.md` children. Matches the Claude plugin manifest convention
|
|
318
|
+
* that lets a skill path point at a directory containing `SKILL.md` directly (e.g.
|
|
319
|
+
* `"skills": ["./"]`), where the frontmatter `name` determines the invocation name and the
|
|
320
|
+
* directory basename is the fallback. Default `false` preserves the strict child-scan
|
|
321
|
+
* semantic every non-Claude provider relies on.
|
|
322
|
+
*/
|
|
323
|
+
includeSelf?: boolean;
|
|
315
324
|
}
|
|
316
325
|
|
|
317
326
|
// Stable ordering used for skill lists in prompts: name (case-insensitive), then name, then path.
|
|
@@ -368,7 +377,13 @@ export async function scanSkillsFromDir(
|
|
|
368
377
|
}
|
|
369
378
|
};
|
|
370
379
|
|
|
371
|
-
const work = [];
|
|
380
|
+
const work: Promise<void>[] = [];
|
|
381
|
+
if (options.includeSelf) {
|
|
382
|
+
const selfSkillPath = path.join(dir, "SKILL.md");
|
|
383
|
+
if (fs.existsSync(selfSkillPath)) {
|
|
384
|
+
work.push(loadSkill(selfSkillPath));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
372
387
|
for (const entry of entries) {
|
|
373
388
|
if (entry.name.startsWith(".")) continue;
|
|
374
389
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
package/src/edit/renderer.ts
CHANGED
|
@@ -679,21 +679,35 @@ function wrapEditRendererLine(line: string, width: number): string[] {
|
|
|
679
679
|
const startAnsi = line.match(/^((?:\x1b\[[0-9;]*m)*)/)?.[1] ?? "";
|
|
680
680
|
const bodyWithReset = line.slice(startAnsi.length);
|
|
681
681
|
const body = bodyWithReset.endsWith("\x1b[39m") ? bodyWithReset.slice(0, -"\x1b[39m".length) : bodyWithReset;
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
682
|
+
// Gutter shapes produced by formatCodeFrameLine: "-315│", " 313│", "+322│",
|
|
683
|
+
// plus the deduplicated forms " +│" and " │" whose repeated line number
|
|
684
|
+
// renderDiff blanked (single-line replacement pairs and insert-then-context
|
|
685
|
+
// runs) — all │-separated. ASCII "|" gutters exist only in raw canonical
|
|
686
|
+
// diff rows passed through by the plain fallback ("-42|old", " 42|ctx"),
|
|
687
|
+
// which always carry a marker column ("+"/"-"/space) and a line number. So
|
|
688
|
+
// the number is optional for "│", while "|" requires the full canonical
|
|
689
|
+
// shape; anything else (a body line merely starting with "|", error text
|
|
690
|
+
// like "123|…") is not a diff row and wraps generically.
|
|
691
|
+
const diffMatch = /^(\s*[+-]?\s*\d*)([|│])(.*)$/s.exec(body);
|
|
692
|
+
|
|
693
|
+
if (!diffMatch || diffMatch[1].length === 0 || (diffMatch[2] === "|" && !/^[+\-\s]\s*\d+$/.test(diffMatch[1]))) {
|
|
685
694
|
return wrapTextWithAnsi(line, width);
|
|
686
695
|
}
|
|
687
696
|
|
|
688
|
-
const [,
|
|
689
|
-
const prefix = `${
|
|
697
|
+
const [, gutter, separator, content] = diffMatch;
|
|
698
|
+
const prefix = `${gutter}${separator}`;
|
|
690
699
|
const prefixWidth = visibleWidth(prefix);
|
|
691
700
|
const contentWidth = Math.max(1, width - prefixWidth);
|
|
692
701
|
const continuationPrefix = `${" ".repeat(Math.max(0, prefixWidth - 1))}${separator}`;
|
|
693
702
|
const wrappedContent = wrapTextWithAnsi(content ?? "", contentWidth);
|
|
694
703
|
|
|
704
|
+
// Each visual row is a standalone terminal line: wrapTextWithAnsi re-opens
|
|
705
|
+
// active SGR state at the next row's start, so a row that breaks inside an
|
|
706
|
+
// intra-line diff highlight still ends with inverse video active. Close it
|
|
707
|
+
// alongside the foreground reset — otherwise the frame padding appended
|
|
708
|
+
// after the row is painted as an inverse block (default-foreground cells).
|
|
695
709
|
return wrappedContent.map(
|
|
696
|
-
(segment, index) => `${startAnsi}${index === 0 ? prefix : continuationPrefix}${segment}\x1b[39m`,
|
|
710
|
+
(segment, index) => `${startAnsi}${index === 0 ? prefix : continuationPrefix}${segment}\x1b[27m\x1b[39m`,
|
|
697
711
|
);
|
|
698
712
|
}
|
|
699
713
|
|