@sayknow-cli/coding-agent 0.3.8 → 0.3.10
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 +13 -0
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/notify-cli.d.ts +5 -0
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/config/model-profile-activation.d.ts +7 -7
- package/dist/types/config/model-registry.d.ts +11 -3
- package/dist/types/config/model-resolver.d.ts +2 -2
- package/dist/types/config/settings-schema.d.ts +15 -1
- package/dist/types/config/settings.d.ts +7 -0
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +20 -0
- package/dist/types/modes/components/settings-selector.d.ts +2 -2
- package/dist/types/modes/components/thinking-selector.d.ts +7 -2
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
- package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
- package/dist/types/modes/types.d.ts +3 -0
- package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
- package/dist/types/notifications/config-commands.d.ts +38 -0
- package/dist/types/notifications/config.d.ts +16 -0
- package/dist/types/notifications/index.d.ts +14 -1
- package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
- package/dist/types/notifications/reply-sent-store.d.ts +53 -0
- package/dist/types/notifications/rich-draft.d.ts +68 -0
- package/dist/types/notifications/rich-render.d.ts +90 -0
- package/dist/types/notifications/telegram-daemon.d.ts +22 -0
- package/dist/types/notifications/telegram-reference.d.ts +7 -0
- package/dist/types/notifications/threaded-render.d.ts +10 -0
- package/dist/types/notifications/topic-registry.d.ts +4 -5
- package/dist/types/runtime-credential-selector.d.ts +7 -0
- package/dist/types/sdk.d.ts +7 -1
- package/dist/types/session/agent-session.d.ts +6 -3
- package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/skill-discovery.d.ts +40 -0
- package/dist/types/utils/pasted-image-path.d.ts +30 -0
- package/package.json +7 -7
- package/src/cli/args.ts +8 -0
- package/src/cli/notify-cli.ts +38 -10
- package/src/cli.ts +5 -0
- package/src/commands/launch.ts +5 -0
- package/src/commands/notify.ts +2 -2
- package/src/config/keybindings.ts +1 -1
- package/src/config/model-profile-activation.ts +23 -7
- package/src/config/model-registry.ts +107 -11
- package/src/config/model-resolver.ts +5 -16
- package/src/config/settings-schema.ts +11 -2
- package/src/config/settings.ts +24 -1
- package/src/extensibility/runtime-skill-discovery.ts +229 -0
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/lsp/client.ts +1 -0
- package/src/main.ts +26 -3
- package/src/modes/components/footer.ts +7 -2
- package/src/modes/components/settings-selector.ts +3 -3
- package/src/modes/components/thinking-selector.ts +83 -20
- package/src/modes/controllers/event-controller.ts +11 -2
- package/src/modes/controllers/extension-ui-controller.ts +8 -1
- package/src/modes/controllers/input-controller.ts +62 -26
- package/src/modes/controllers/selector-controller.ts +43 -0
- package/src/modes/interactive-mode.ts +5 -0
- package/src/modes/rpc/rpc-mode.ts +3 -9
- package/src/modes/rpc/rpc-socket-security.ts +13 -1
- package/src/modes/types.ts +3 -0
- package/src/modes/utils/injected-user-submission.ts +94 -0
- package/src/modes/utils/ui-helpers.ts +48 -15
- package/src/notifications/config-commands.ts +90 -0
- package/src/notifications/config.ts +27 -0
- package/src/notifications/index.ts +184 -14
- package/src/notifications/lifecycle-commands.ts +37 -16
- package/src/notifications/lifecycle-control-runtime.ts +8 -3
- package/src/notifications/reply-sent-store.ts +134 -0
- package/src/notifications/rich-draft.ts +107 -0
- package/src/notifications/rich-render.ts +143 -0
- package/src/notifications/telegram-daemon.ts +431 -91
- package/src/notifications/telegram-reference.ts +17 -0
- package/src/notifications/threaded-render.ts +42 -1
- package/src/notifications/topic-registry.ts +9 -8
- package/src/prompts/tools/search-tool-bm25.md +5 -0
- package/src/prompts/tools/skill-discovery.md +13 -0
- package/src/runtime-credential-selector.ts +35 -0
- package/src/sdk.ts +104 -13
- package/src/session/agent-session.ts +48 -22
- package/src/skc-runtime/psmux-detect.ts +17 -2
- package/src/skc-runtime/tmux-common.ts +1 -0
- package/src/skc-runtime/ultragoal-runtime.ts +10 -1
- package/src/slash-commands/builtin-registry.ts +78 -1
- package/src/system-prompt.ts +4 -6
- package/src/tools/index.ts +4 -0
- package/src/tools/skill-discovery.ts +73 -0
- package/src/tools/skill.ts +15 -2
- package/src/utils/pasted-image-path.ts +170 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import
|
|
3
|
+
import { ThinkingLevel } from "@sayknow-cli/agent-core";
|
|
4
4
|
import { type Model, modelsAreEqual } from "@sayknow-cli/ai";
|
|
5
5
|
import { getOAuthProviders } from "@sayknow-cli/ai/utils/oauth";
|
|
6
6
|
import { Spacer, Text } from "@sayknow-cli/tui";
|
|
@@ -380,6 +380,60 @@ function modelSelectionUsage(runtime: SlashCommandRuntime, currentModelLine?: st
|
|
|
380
380
|
.join("\n\n");
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
+
const EFFORT_COMMAND_INPUT_HINT = "[inherit|off|minimal|low|medium|high|xhigh|max]";
|
|
384
|
+
const EFFORT_COMMAND_ACCEPTED_VALUES = ["inherit", "off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
385
|
+
|
|
386
|
+
function effortCommandUsage(prefix?: string): string {
|
|
387
|
+
return [prefix, `Usage: /effort ${EFFORT_COMMAND_INPUT_HINT}`]
|
|
388
|
+
.filter((line): line is string => Boolean(line))
|
|
389
|
+
.join("\n");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function formatEffortStatus(runtime: SlashCommandRuntime): string {
|
|
393
|
+
const current = runtime.session.thinkingLevel ?? ThinkingLevel.Off;
|
|
394
|
+
const configuredDefault = runtime.settings.get("defaultThinkingLevel");
|
|
395
|
+
const supported = runtime.session.getAvailableThinkingLevels();
|
|
396
|
+
return [
|
|
397
|
+
`Current effective effort: ${current}`,
|
|
398
|
+
`Configured default effort: ${configuredDefault}`,
|
|
399
|
+
`Accepted values: ${EFFORT_COMMAND_ACCEPTED_VALUES.join(", ")}`,
|
|
400
|
+
`Current-model supported levels: ${supported.length > 0 ? supported.join(", ") : "(none reported)"}`,
|
|
401
|
+
].join("\n");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function handleEffortCommand(
|
|
405
|
+
command: ParsedSlashCommand,
|
|
406
|
+
runtime: SlashCommandRuntime,
|
|
407
|
+
): Promise<SlashCommandResult> {
|
|
408
|
+
const tokens = command.args.trim().split(/\s+/).filter(Boolean);
|
|
409
|
+
if (tokens.length === 0) {
|
|
410
|
+
await runtime.output(formatEffortStatus(runtime));
|
|
411
|
+
return commandConsumed();
|
|
412
|
+
}
|
|
413
|
+
if (tokens.length !== 1) {
|
|
414
|
+
return usage(effortCommandUsage("Invalid effort input."), runtime);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const requestedToken = tokens[0];
|
|
418
|
+
const requestedLevel = parseThinkingLevel(requestedToken);
|
|
419
|
+
if (!requestedToken || !requestedLevel) {
|
|
420
|
+
return usage(effortCommandUsage(`Invalid effort: ${tokens[0] ?? ""}.`), runtime);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const levelToApply =
|
|
424
|
+
requestedLevel === ThinkingLevel.Inherit ? runtime.settings.get("defaultThinkingLevel") : requestedLevel;
|
|
425
|
+
runtime.session.setThinkingLevel(levelToApply, false);
|
|
426
|
+
const effectiveLevel = runtime.session.thinkingLevel ?? ThinkingLevel.Off;
|
|
427
|
+
const requestedLabel =
|
|
428
|
+
requestedLevel === ThinkingLevel.Inherit ? `${requestedLevel} (${levelToApply})` : requestedLevel;
|
|
429
|
+
const clampedSuffix =
|
|
430
|
+
effectiveLevel === levelToApply ? "" : ` Requested ${levelToApply}; effective ${effectiveLevel}.`;
|
|
431
|
+
await runtime.output(
|
|
432
|
+
`Reasoning effort set to ${requestedLabel}. Effective effort: ${effectiveLevel}.${clampedSuffix}`,
|
|
433
|
+
);
|
|
434
|
+
return commandConsumed();
|
|
435
|
+
}
|
|
436
|
+
|
|
383
437
|
function refreshStatusLine(ctx: InteractiveModeContext): void {
|
|
384
438
|
ctx.statusLine.invalidate();
|
|
385
439
|
ctx.updateEditorTopBorder();
|
|
@@ -588,6 +642,29 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
588
642
|
runtime.ctx.editor.setText("");
|
|
589
643
|
},
|
|
590
644
|
},
|
|
645
|
+
{
|
|
646
|
+
name: "effort",
|
|
647
|
+
description: "Show or set model reasoning effort",
|
|
648
|
+
acpDescription: "Show or set model reasoning effort",
|
|
649
|
+
inlineHint: EFFORT_COMMAND_INPUT_HINT,
|
|
650
|
+
acpInputHint: EFFORT_COMMAND_INPUT_HINT,
|
|
651
|
+
allowArgs: true,
|
|
652
|
+
handle: handleEffortCommand,
|
|
653
|
+
handleTui: async (command, runtime) => {
|
|
654
|
+
if (command.args.trim()) {
|
|
655
|
+
const result = await handleEffortCommand(command, toSlashCommandRuntime(runtime));
|
|
656
|
+
runtime.ctx.statusLine.invalidate();
|
|
657
|
+
runtime.ctx.updateEditorBorderColor();
|
|
658
|
+
runtime.ctx.updateEditorTopBorder();
|
|
659
|
+
runtime.ctx.editor.setText("");
|
|
660
|
+
runtime.ctx.ui.requestRender();
|
|
661
|
+
return result;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
runtime.ctx.showEffortSelector();
|
|
665
|
+
runtime.ctx.editor.setText("");
|
|
666
|
+
},
|
|
667
|
+
},
|
|
591
668
|
{
|
|
592
669
|
name: "fast",
|
|
593
670
|
description: "Toggle priority service tier (OpenAI service_tier=priority, Anthropic speed=fast)",
|
package/src/system-prompt.ts
CHANGED
|
@@ -507,7 +507,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
507
507
|
? loadSkills({ ...skillsSettings, cwd: resolvedCwd }).then(result => result.skills)
|
|
508
508
|
: Promise.resolve([]);
|
|
509
509
|
|
|
510
|
-
const [resolvedCustomPrompt, resolvedAppendPrompt, systemPromptCustomization, contextFiles,
|
|
510
|
+
const [resolvedCustomPrompt, resolvedAppendPrompt, systemPromptCustomization, contextFiles, , workspaceTree] =
|
|
511
511
|
await Promise.all([
|
|
512
512
|
withDeadline(
|
|
513
513
|
"customPrompt",
|
|
@@ -581,11 +581,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
581
581
|
description: tools?.get(name)?.description ?? "",
|
|
582
582
|
}));
|
|
583
583
|
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
const hasRead = tools?.has("read");
|
|
588
|
-
const filteredSkills = hasRead ? skills.filter(skill => skill.hide !== true) : [];
|
|
584
|
+
// Runtime skills are discovered by `skill_discovery` and loaded narrowly through
|
|
585
|
+
// `skill`; do not dump the full custom skill catalog into the core prompt.
|
|
586
|
+
const filteredSkills: Skill[] = [];
|
|
589
587
|
|
|
590
588
|
const effectiveSystemPromptCustomization = dedupePromptSource(systemPromptCustomization, [
|
|
591
589
|
resolvedCustomPrompt,
|
package/src/tools/index.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { reportFindingTool } from "./review";
|
|
|
56
56
|
import { SearchTool } from "./search";
|
|
57
57
|
import { SearchToolBm25Tool } from "./search-tool-bm25";
|
|
58
58
|
import { SkillTool } from "./skill";
|
|
59
|
+
import { SkillDiscoveryTool } from "./skill-discovery";
|
|
59
60
|
import { loadSshTool } from "./ssh";
|
|
60
61
|
import { SubagentTool } from "./subagent";
|
|
61
62
|
import { TelegramSendTool } from "./telegram-send";
|
|
@@ -95,6 +96,7 @@ export * from "./review";
|
|
|
95
96
|
export * from "./search";
|
|
96
97
|
export * from "./search-tool-bm25";
|
|
97
98
|
export * from "./skill";
|
|
99
|
+
export * from "./skill-discovery";
|
|
98
100
|
export * from "./ssh";
|
|
99
101
|
export * from "./subagent";
|
|
100
102
|
export * from "./telegram-send";
|
|
@@ -411,6 +413,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
|
|
|
411
413
|
todo_write: s => new TodoWriteTool(s),
|
|
412
414
|
web_search: s => new WebSearchTool(s),
|
|
413
415
|
search_tool_bm25: SearchToolBm25Tool.createIf,
|
|
416
|
+
skill_discovery: SkillDiscoveryTool.createIf,
|
|
414
417
|
telegram_send: TelegramSendTool.createIf,
|
|
415
418
|
write: s => new WriteTool(s),
|
|
416
419
|
skill: SkillTool.createIf,
|
|
@@ -570,6 +573,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
|
|
570
573
|
if (name === "search_tool_bm25") return discoveryActive;
|
|
571
574
|
if (name === "calc") return session.settings.get("calc.enabled");
|
|
572
575
|
if (name === "skill") return session.settings.get("skill.enabled");
|
|
576
|
+
if (name === "skill_discovery") return session.settings.get("skill.enabled");
|
|
573
577
|
if (name === "browser") return session.settings.get("browser.enabled");
|
|
574
578
|
if (name === "computer") return isComputerCallable(session);
|
|
575
579
|
if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { AgentTool, AgentToolResult } from "@sayknow-cli/agent-core";
|
|
2
|
+
import { prompt, untilAborted } from "@sayknow-cli/utils";
|
|
3
|
+
import * as z from "zod/v4";
|
|
4
|
+
import { discoverRuntimeSkills, type RuntimeSkillDiscoveryCandidate } from "../extensibility/runtime-skill-discovery";
|
|
5
|
+
import skillDiscoveryDescription from "../prompts/tools/skill-discovery.md" with { type: "text" };
|
|
6
|
+
import type { ToolSession } from ".";
|
|
7
|
+
|
|
8
|
+
const skillDiscoverySchema = z
|
|
9
|
+
.object({
|
|
10
|
+
query: z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe("words to match against skill name, description, source, or use conditions"),
|
|
14
|
+
source: z.enum(["all", "project", "user"]).default("all").optional().describe("skill source scope to search"),
|
|
15
|
+
limit: z.number().min(1).max(50).default(20).optional().describe("maximum results"),
|
|
16
|
+
})
|
|
17
|
+
.strict();
|
|
18
|
+
|
|
19
|
+
export type SkillDiscoveryToolInput = z.infer<typeof skillDiscoverySchema>;
|
|
20
|
+
|
|
21
|
+
export interface SkillDiscoveryToolDetails {
|
|
22
|
+
candidates: RuntimeSkillDiscoveryCandidate[];
|
|
23
|
+
count: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class SkillDiscoveryTool implements AgentTool<typeof skillDiscoverySchema, SkillDiscoveryToolDetails> {
|
|
27
|
+
readonly name = "skill_discovery";
|
|
28
|
+
readonly label = "SkillDiscovery";
|
|
29
|
+
readonly summary = "Discover project and user runtime skills by thin metadata";
|
|
30
|
+
readonly loadMode = "essential";
|
|
31
|
+
readonly description: string;
|
|
32
|
+
readonly parameters = skillDiscoverySchema;
|
|
33
|
+
readonly strict = true;
|
|
34
|
+
|
|
35
|
+
readonly #session: ToolSession;
|
|
36
|
+
|
|
37
|
+
constructor(session: ToolSession) {
|
|
38
|
+
this.#session = session;
|
|
39
|
+
this.description = prompt.render(skillDiscoveryDescription);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#getRuntimeSkillPolicy() {
|
|
43
|
+
return {
|
|
44
|
+
...this.#session.settings.getGroup("skills"),
|
|
45
|
+
disabledExtensions: this.#session.settings.get("disabledExtensions"),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static createIf(session: ToolSession): SkillDiscoveryTool | null {
|
|
50
|
+
if (session.settings.get("skill.enabled") === false) return null;
|
|
51
|
+
return new SkillDiscoveryTool(session);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async execute(
|
|
55
|
+
_toolCallId: string,
|
|
56
|
+
input: SkillDiscoveryToolInput,
|
|
57
|
+
signal?: AbortSignal,
|
|
58
|
+
): Promise<AgentToolResult<SkillDiscoveryToolDetails>> {
|
|
59
|
+
return untilAborted(signal, async () => {
|
|
60
|
+
const candidates = await discoverRuntimeSkills({
|
|
61
|
+
cwd: this.#session.cwd,
|
|
62
|
+
query: input.query,
|
|
63
|
+
source: input.source ?? "all",
|
|
64
|
+
limit: input.limit,
|
|
65
|
+
policy: this.#getRuntimeSkillPolicy(),
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
content: [{ type: "text", text: JSON.stringify({ candidates, count: candidates.length }, null, 2) }],
|
|
69
|
+
details: { candidates, count: candidates.length },
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/tools/skill.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import type { AgentTool, AgentToolResult } from "@sayknow-cli/agent-core";
|
|
19
19
|
import { prompt, untilAborted } from "@sayknow-cli/utils";
|
|
20
20
|
import * as z from "zod/v4";
|
|
21
|
+
import { findRuntimeSkillByName } from "../extensibility/runtime-skill-discovery";
|
|
21
22
|
import { resolveSubskillActivationForSkillInvocation } from "../extensibility/skc-plugins";
|
|
22
23
|
import { buildSkillPromptMessage } from "../extensibility/skills";
|
|
23
24
|
import skillDescription from "../prompts/tools/skill.md" with { type: "text" };
|
|
@@ -64,6 +65,13 @@ export class SkillTool implements AgentTool<typeof skillSchema, SkillToolDetails
|
|
|
64
65
|
this.description = prompt.render(skillDescription);
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
#getRuntimeSkillPolicy() {
|
|
69
|
+
return {
|
|
70
|
+
...this.#session.settings.getGroup("skills"),
|
|
71
|
+
disabledExtensions: this.#session.settings.get("disabledExtensions"),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
67
75
|
static createIf(session: ToolSession): SkillTool | null {
|
|
68
76
|
// The tool can only chain when the session can deliver the same-turn
|
|
69
77
|
// custom message. Without `sendCustomMessage` (e.g. minimal tool
|
|
@@ -110,10 +118,15 @@ export class SkillTool implements AgentTool<typeof skillSchema, SkillToolDetails
|
|
|
110
118
|
);
|
|
111
119
|
}
|
|
112
120
|
|
|
113
|
-
const skill =
|
|
121
|
+
const skill =
|
|
122
|
+
skills.find(s => s.name === requestedName) ??
|
|
123
|
+
(await findRuntimeSkillByName(this.#session.cwd, requestedName, this.#getRuntimeSkillPolicy()));
|
|
114
124
|
if (!skill) {
|
|
115
125
|
const available = skills.map(s => s.name).sort();
|
|
116
|
-
const hint =
|
|
126
|
+
const hint =
|
|
127
|
+
available.length > 0
|
|
128
|
+
? ` Available: ${available.join(", ")}. Use skill_discovery to find project/user runtime skills.`
|
|
129
|
+
: " Use skill_discovery to find project/user runtime skills.";
|
|
117
130
|
throw new ToolError(`skill tool: unknown skill "${requestedName}".${hint}`);
|
|
118
131
|
}
|
|
119
132
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve pasted editor text to an image file path.
|
|
3
|
+
*
|
|
4
|
+
* Terminals insert a shell-escaped filesystem path when a file is drag-dropped
|
|
5
|
+
* onto them (e.g. macOS `Screenshot\ 2026-07-07\ at\ 11.06.38 PM.png`, where
|
|
6
|
+
* the visible "space" before AM/PM is U+202F and stays unescaped). When the
|
|
7
|
+
* entire paste is a single path to an existing image file, the interactive
|
|
8
|
+
* editor attaches the image and inserts an `[image N]` placeholder instead of
|
|
9
|
+
* leaving the raw path in the prompt.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const IMAGE_FILE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp)$/i;
|
|
17
|
+
|
|
18
|
+
export interface DecodePastedPathOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Platform whose path semantics apply when decoding (shell unescaping,
|
|
21
|
+
* `file://` drive letters / UNC hosts). Defaults to `process.platform`;
|
|
22
|
+
* injectable so tests can pin the win32 contract from any host.
|
|
23
|
+
*/
|
|
24
|
+
platform?: NodeJS.Platform;
|
|
25
|
+
/** Home directory for `~/` expansion. Defaults to `os.homedir()`. */
|
|
26
|
+
homedir?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ResolvePastedImagePathOptions extends DecodePastedPathOptions {
|
|
30
|
+
/** Base directory for relative paths. Defaults to `process.cwd()`. */
|
|
31
|
+
cwd?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Convert a `file://` URI to a filesystem path for the given platform, or
|
|
36
|
+
* `undefined` when the URI is invalid.
|
|
37
|
+
*
|
|
38
|
+
* The production path (injected platform === host platform, always true
|
|
39
|
+
* outside tests) delegates to Node's `fileURLToPath`. When tests inject a
|
|
40
|
+
* foreign platform, a mirror of `fileURLToPath`'s rules is applied instead —
|
|
41
|
+
* Bun (1.3) accepts but ignores `fileURLToPath`'s `windows` option, so the
|
|
42
|
+
* native call cannot emulate a foreign platform.
|
|
43
|
+
*/
|
|
44
|
+
function decodeFileUrl(candidate: string, platform: NodeJS.Platform): string | undefined {
|
|
45
|
+
let url: URL;
|
|
46
|
+
try {
|
|
47
|
+
url = new URL(candidate);
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
if (url.protocol !== "file:") return undefined;
|
|
52
|
+
|
|
53
|
+
if (platform === process.platform) {
|
|
54
|
+
try {
|
|
55
|
+
return fileURLToPath(url);
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Foreign-platform mirror of fileURLToPath (tests only).
|
|
62
|
+
if (/%2f/i.test(url.pathname) || (platform === "win32" && /%5c/i.test(url.pathname))) return undefined;
|
|
63
|
+
let pathname: string;
|
|
64
|
+
try {
|
|
65
|
+
pathname = decodeURIComponent(url.pathname);
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
if (platform === "win32") {
|
|
70
|
+
if (url.hostname && url.hostname !== "localhost") {
|
|
71
|
+
// UNC: file://server/share/img.png -> \\server\share\img.png
|
|
72
|
+
return `\\\\${url.hostname}${pathname.replaceAll("/", "\\")}`;
|
|
73
|
+
}
|
|
74
|
+
// Drive letter required: file:///C:/x -> C:\x
|
|
75
|
+
if (!/^\/[A-Za-z]:/.test(pathname)) return undefined;
|
|
76
|
+
return pathname.slice(1).replaceAll("/", "\\");
|
|
77
|
+
}
|
|
78
|
+
if (url.hostname && url.hostname !== "localhost") return undefined;
|
|
79
|
+
return pathname;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decode pasted text into a filesystem path candidate. No filesystem access.
|
|
84
|
+
*
|
|
85
|
+
* Handles terminal drag-drop shell escaping (`\ `, `\(`, ...; skipped on
|
|
86
|
+
* win32 where `\` is the path separator), quoted paths, `file://` URIs
|
|
87
|
+
* (drive-letter, `file://localhost`, and UNC forms on win32), and `~/`
|
|
88
|
+
* expansion. Returns `undefined` when the text is empty, spans multiple
|
|
89
|
+
* lines, or is an invalid `file://` URI.
|
|
90
|
+
*/
|
|
91
|
+
export function decodePastedPathCandidate(text: string, options?: DecodePastedPathOptions): string | undefined {
|
|
92
|
+
const platform = options?.platform ?? process.platform;
|
|
93
|
+
let candidate = text.trim();
|
|
94
|
+
if (!candidate || /[\r\n]/.test(candidate)) return undefined;
|
|
95
|
+
|
|
96
|
+
// Quoted paths (some terminals quote instead of escaping).
|
|
97
|
+
if (
|
|
98
|
+
candidate.length >= 2 &&
|
|
99
|
+
(candidate.startsWith('"') || candidate.startsWith("'")) &&
|
|
100
|
+
candidate.endsWith(candidate[0])
|
|
101
|
+
) {
|
|
102
|
+
candidate = candidate.slice(1, -1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (candidate.startsWith("file://")) {
|
|
106
|
+
const decoded = decodeFileUrl(candidate, platform);
|
|
107
|
+
if (decoded === undefined) return undefined;
|
|
108
|
+
candidate = decoded;
|
|
109
|
+
} else if (platform !== "win32") {
|
|
110
|
+
// Terminal drag-drop escapes shell-special characters (`\ `, `\(`, ...).
|
|
111
|
+
// Skipped on Windows where `\` is the path separator.
|
|
112
|
+
candidate = candidate.replace(/\\(.)/g, "$1");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (candidate.startsWith("~/")) {
|
|
116
|
+
candidate = path.join(options?.homedir ?? os.homedir(), candidate.slice(2));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return candidate;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Sniff the file header for a supported image signature (PNG, JPEG, GIF,
|
|
124
|
+
* WEBP). Prevents existing non-image files with image-looking extensions
|
|
125
|
+
* (e.g. a text file named `not-image.png`) from being treated as image
|
|
126
|
+
* candidates — consuming such a paste would lose the raw path when the
|
|
127
|
+
* image loader later rejects the content.
|
|
128
|
+
*/
|
|
129
|
+
function hasSupportedImageMagic(filePath: string): boolean {
|
|
130
|
+
let fd: number;
|
|
131
|
+
try {
|
|
132
|
+
fd = fs.openSync(filePath, "r");
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
// Zero-filled; short reads leave trailing zeros so comparisons fail safely.
|
|
138
|
+
const header = Buffer.alloc(12);
|
|
139
|
+
fs.readSync(fd, header, 0, 12, 0);
|
|
140
|
+
if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4e && header[3] === 0x47) return true; // PNG
|
|
141
|
+
if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) return true; // JPEG
|
|
142
|
+
const ascii6 = header.toString("latin1", 0, 6);
|
|
143
|
+
if (ascii6 === "GIF87a" || ascii6 === "GIF89a") return true; // GIF
|
|
144
|
+
if (header.toString("latin1", 0, 4) === "RIFF" && header.toString("latin1", 8, 12) === "WEBP") return true; // WEBP
|
|
145
|
+
return false;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
} finally {
|
|
149
|
+
fs.closeSync(fd);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Returns the resolved path when the whole pasted text is a single path to an
|
|
155
|
+
* existing image file (verified by extension AND content signature),
|
|
156
|
+
* otherwise `undefined` (the paste is inserted as text).
|
|
157
|
+
*/
|
|
158
|
+
export function resolvePastedImagePath(text: string, options?: ResolvePastedImagePathOptions): string | undefined {
|
|
159
|
+
const candidate = decodePastedPathCandidate(text, options);
|
|
160
|
+
if (!candidate || !IMAGE_FILE_EXTENSION_PATTERN.test(candidate)) return undefined;
|
|
161
|
+
|
|
162
|
+
const resolved = path.resolve(options?.cwd ?? process.cwd(), candidate);
|
|
163
|
+
try {
|
|
164
|
+
if (!fs.statSync(resolved).isFile()) return undefined;
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
if (!hasSupportedImageMagic(resolved)) return undefined;
|
|
169
|
+
return resolved;
|
|
170
|
+
}
|