@oh-my-pi/pi-coding-agent 4.3.2 → 4.4.6

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 (54) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +5 -6
  3. package/src/core/frontmatter.ts +98 -0
  4. package/src/core/keybindings.ts +1 -1
  5. package/src/core/prompt-templates.ts +5 -34
  6. package/src/core/sdk.ts +3 -0
  7. package/src/core/skills.ts +3 -3
  8. package/src/core/slash-commands.ts +14 -5
  9. package/src/core/tools/calculator.ts +1 -1
  10. package/src/core/tools/edit.ts +2 -2
  11. package/src/core/tools/exa/render.ts +23 -11
  12. package/src/core/tools/index.test.ts +2 -0
  13. package/src/core/tools/index.ts +3 -0
  14. package/src/core/tools/jtd-to-json-schema.ts +1 -6
  15. package/src/core/tools/ls.ts +5 -2
  16. package/src/core/tools/lsp/config.ts +2 -2
  17. package/src/core/tools/lsp/render.ts +33 -12
  18. package/src/core/tools/notebook.ts +1 -1
  19. package/src/core/tools/output.ts +1 -1
  20. package/src/core/tools/read.ts +15 -49
  21. package/src/core/tools/render-utils.ts +61 -24
  22. package/src/core/tools/renderers.ts +2 -0
  23. package/src/core/tools/schema-validation.test.ts +501 -0
  24. package/src/core/tools/task/agents.ts +6 -2
  25. package/src/core/tools/task/commands.ts +9 -3
  26. package/src/core/tools/task/discovery.ts +3 -2
  27. package/src/core/tools/task/render.ts +10 -7
  28. package/src/core/tools/todo-write.ts +256 -0
  29. package/src/core/tools/web-fetch.ts +4 -2
  30. package/src/core/tools/web-scrapers/choosealicense.ts +2 -2
  31. package/src/core/tools/web-search/render.ts +13 -10
  32. package/src/core/tools/write.ts +2 -2
  33. package/src/discovery/builtin.ts +4 -4
  34. package/src/discovery/cline.ts +4 -3
  35. package/src/discovery/codex.ts +3 -3
  36. package/src/discovery/cursor.ts +2 -2
  37. package/src/discovery/github.ts +3 -2
  38. package/src/discovery/helpers.test.ts +14 -10
  39. package/src/discovery/helpers.ts +2 -39
  40. package/src/discovery/windsurf.ts +3 -3
  41. package/src/modes/interactive/components/custom-editor.ts +4 -11
  42. package/src/modes/interactive/components/index.ts +2 -1
  43. package/src/modes/interactive/components/read-tool-group.ts +118 -0
  44. package/src/modes/interactive/components/todo-display.ts +112 -0
  45. package/src/modes/interactive/components/tool-execution.ts +18 -2
  46. package/src/modes/interactive/controllers/command-controller.ts +2 -2
  47. package/src/modes/interactive/controllers/event-controller.ts +91 -32
  48. package/src/modes/interactive/controllers/input-controller.ts +19 -13
  49. package/src/modes/interactive/interactive-mode.ts +103 -3
  50. package/src/modes/interactive/theme/theme.ts +4 -0
  51. package/src/modes/interactive/types.ts +14 -2
  52. package/src/modes/interactive/utils/ui-helpers.ts +55 -26
  53. package/src/prompts/system/system-prompt.md +177 -126
  54. package/src/prompts/tools/todo-write.md +187 -0
@@ -0,0 +1,256 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync } from "node:fs";
3
+ import path from "node:path";
4
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
5
+ import type { Component } from "@oh-my-pi/pi-tui";
6
+ import { Text } from "@oh-my-pi/pi-tui";
7
+ import { Type } from "@sinclair/typebox";
8
+ import chalk from "chalk";
9
+ import type { Theme } from "../../modes/interactive/theme/theme";
10
+ import todoWriteDescription from "../../prompts/tools/todo-write.md" with { type: "text" };
11
+ import type { RenderResultOptions } from "../custom-tools/types";
12
+ import { logger } from "../logger";
13
+ import { renderPromptTemplate } from "../prompt-templates";
14
+ import type { ToolSession } from "../sdk";
15
+ import { ensureArtifactsDir, getArtifactsDir } from "./task/artifacts";
16
+
17
+ const todoWriteSchema = Type.Object({
18
+ todos: Type.Array(
19
+ Type.Object({
20
+ id: Type.Optional(Type.String({ description: "Stable todo id" })),
21
+ content: Type.String({ minLength: 1, description: "Imperative task description (e.g., 'Run tests')" }),
22
+ activeForm: Type.String({ minLength: 1, description: "Present continuous form (e.g., 'Running tests')" }),
23
+ status: Type.Union([Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("completed")]),
24
+ }),
25
+ { description: "The updated todo list" },
26
+ ),
27
+ });
28
+
29
+ type TodoStatus = "pending" | "in_progress" | "completed";
30
+
31
+ export interface TodoItem {
32
+ id: string;
33
+ content: string;
34
+ activeForm: string;
35
+ status: TodoStatus;
36
+ }
37
+
38
+ interface TodoFile {
39
+ updatedAt: number;
40
+ todos: TodoItem[];
41
+ }
42
+
43
+ export interface TodoWriteToolDetails {
44
+ todos: TodoItem[];
45
+ updatedAt: number;
46
+ storage: "session" | "memory";
47
+ }
48
+
49
+ const TODO_FILE_NAME = "todos.json";
50
+
51
+ function normalizeTodoStatus(status?: string): TodoStatus {
52
+ switch (status) {
53
+ case "in_progress":
54
+ return "in_progress";
55
+ case "completed":
56
+ case "done":
57
+ case "complete":
58
+ return "completed";
59
+ default:
60
+ return "pending";
61
+ }
62
+ }
63
+
64
+ function normalizeTodos(
65
+ items: Array<{ id?: string; content?: string; activeForm?: string; status?: string }>,
66
+ ): TodoItem[] {
67
+ return items.map((item) => {
68
+ if (!item.content || !item.activeForm) {
69
+ throw new Error("Todo content and activeForm are required.");
70
+ }
71
+ const content = item.content.trim();
72
+ const activeForm = item.activeForm.trim();
73
+ if (!content) {
74
+ throw new Error("Todo content cannot be empty.");
75
+ }
76
+ if (!activeForm) {
77
+ throw new Error("Todo activeForm cannot be empty.");
78
+ }
79
+ return {
80
+ id: item.id && item.id.trim().length > 0 ? item.id : randomUUID(),
81
+ content,
82
+ activeForm,
83
+ status: normalizeTodoStatus(item.status),
84
+ };
85
+ });
86
+ }
87
+
88
+ function validateSequentialTodos(todos: TodoItem[]): { valid: boolean; error?: string } {
89
+ if (todos.length === 0) return { valid: true };
90
+
91
+ const firstIncompleteIndex = todos.findIndex((todo) => todo.status !== "completed");
92
+ if (firstIncompleteIndex >= 0) {
93
+ for (let i = firstIncompleteIndex + 1; i < todos.length; i++) {
94
+ if (todos[i].status === "completed") {
95
+ return {
96
+ valid: false,
97
+ error: `Error: Cannot complete "${todos[i].content}" before completing "${todos[firstIncompleteIndex].content}". Todos must be completed sequentially.`,
98
+ };
99
+ }
100
+ }
101
+ }
102
+
103
+ const inProgressIndices = todos.reduce<number[]>((acc, todo, index) => {
104
+ if (todo.status === "in_progress") acc.push(index);
105
+ return acc;
106
+ }, []);
107
+
108
+ if (inProgressIndices.length > 1) {
109
+ return { valid: false, error: "Only one todo can be in progress at a time." };
110
+ }
111
+
112
+ if (inProgressIndices.length === 1 && firstIncompleteIndex >= 0) {
113
+ if (inProgressIndices[0] !== firstIncompleteIndex) {
114
+ return { valid: false, error: "Todo in progress must be the next incomplete item." };
115
+ }
116
+ }
117
+
118
+ return { valid: true };
119
+ }
120
+
121
+ async function loadTodoFile(filePath: string): Promise<TodoFile | null> {
122
+ const file = Bun.file(filePath);
123
+ if (!(await file.exists())) return null;
124
+ try {
125
+ const text = await file.text();
126
+ const data = JSON.parse(text) as TodoFile;
127
+ if (!data || !Array.isArray(data.todos)) return null;
128
+ return data;
129
+ } catch (error) {
130
+ logger.warn("Failed to read todo file", { path: filePath, error: String(error) });
131
+ return null;
132
+ }
133
+ }
134
+
135
+ async function saveTodoFile(filePath: string, data: TodoFile): Promise<void> {
136
+ await Bun.write(filePath, JSON.stringify(data, null, 2));
137
+ }
138
+
139
+ function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string {
140
+ const checkbox = uiTheme.checkbox;
141
+ const displayText =
142
+ item.status === "in_progress" && item.activeForm !== item.content ? item.activeForm : item.content;
143
+ switch (item.status) {
144
+ case "completed":
145
+ return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`);
146
+ case "in_progress":
147
+ return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${displayText}`);
148
+ default:
149
+ return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`);
150
+ }
151
+ }
152
+
153
+ function formatTodoSummary(todos: TodoItem[]): string {
154
+ if (todos.length === 0) return "Todo list cleared.";
155
+ const completed = todos.filter((t) => t.status === "completed").length;
156
+ const inProgress = todos.filter((t) => t.status === "in_progress").length;
157
+ const pending = todos.filter((t) => t.status === "pending").length;
158
+ return `Saved ${todos.length} todos (${pending} pending, ${inProgress} in progress, ${completed} completed).`;
159
+ }
160
+
161
+ export function createTodoWriteTool(session: ToolSession): AgentTool<typeof todoWriteSchema, TodoWriteToolDetails> {
162
+ return {
163
+ name: "todo_write",
164
+ label: "Todo Write",
165
+ description: renderPromptTemplate(todoWriteDescription),
166
+ parameters: todoWriteSchema,
167
+ execute: async (
168
+ _toolCallId: string,
169
+ params: { todos: Array<{ id?: string; content?: string; activeForm?: string; status?: string }> },
170
+ ) => {
171
+ const todos = normalizeTodos(params.todos ?? []);
172
+ const validation = validateSequentialTodos(todos);
173
+ if (!validation.valid) {
174
+ throw new Error(validation.error ?? "Todos must be completed sequentially.");
175
+ }
176
+ const updatedAt = Date.now();
177
+
178
+ const sessionFile = session.getSessionFile();
179
+ if (!sessionFile) {
180
+ return {
181
+ content: [{ type: "text", text: formatTodoSummary(todos) }],
182
+ details: { todos, updatedAt, storage: "memory" },
183
+ };
184
+ }
185
+
186
+ const artifactsDir = getArtifactsDir(sessionFile);
187
+ if (!artifactsDir) {
188
+ return {
189
+ content: [{ type: "text", text: formatTodoSummary(todos) }],
190
+ details: { todos, updatedAt, storage: "memory" },
191
+ };
192
+ }
193
+
194
+ ensureArtifactsDir(artifactsDir);
195
+ const todoPath = path.join(artifactsDir, TODO_FILE_NAME);
196
+ const existing = await loadTodoFile(todoPath);
197
+ const storedTodos = existing?.todos ?? [];
198
+ const merged = todos.length > 0 ? todos : [];
199
+ const fileData: TodoFile = { updatedAt, todos: merged };
200
+
201
+ try {
202
+ mkdirSync(artifactsDir, { recursive: true });
203
+ await saveTodoFile(todoPath, fileData);
204
+ } catch (error) {
205
+ logger.error("Failed to write todo file", { path: todoPath, error: String(error) });
206
+ return {
207
+ content: [{ type: "text", text: "Failed to save todos." }],
208
+ details: { todos: storedTodos, updatedAt, storage: "session" },
209
+ };
210
+ }
211
+
212
+ return {
213
+ content: [{ type: "text", text: formatTodoSummary(merged) }],
214
+ details: { todos: merged, updatedAt, storage: "session" },
215
+ };
216
+ },
217
+ };
218
+ }
219
+
220
+ // =============================================================================
221
+ // TUI Renderer
222
+ // =============================================================================
223
+
224
+ interface TodoWriteRenderArgs {
225
+ todos?: Array<{ id?: string; content?: string; activeForm?: string; status?: string }>;
226
+ }
227
+
228
+ export const todoWriteToolRenderer = {
229
+ renderCall(args: TodoWriteRenderArgs, uiTheme: Theme): Component {
230
+ const count = args.todos?.length ?? 0;
231
+ const summary = count > 0 ? uiTheme.fg("accent", `${count} items`) : uiTheme.fg("toolOutput", "empty");
232
+ return new Text(`${uiTheme.fg("toolTitle", uiTheme.bold("Todo Write"))} ${summary}`, 0, 0);
233
+ },
234
+
235
+ renderResult(
236
+ result: { content: Array<{ type: string; text?: string }>; details?: TodoWriteToolDetails },
237
+ _options: RenderResultOptions,
238
+ uiTheme: Theme,
239
+ _args?: TodoWriteRenderArgs,
240
+ ): Component {
241
+ const todos = result.details?.todos ?? [];
242
+ const indent = " ";
243
+ const hook = uiTheme.tree.hook;
244
+ const lines = [indent + uiTheme.bold(uiTheme.fg("accent", "Todos"))];
245
+
246
+ if (todos.length > 0) {
247
+ const visibleTodos = todos;
248
+ visibleTodos.forEach((todo, index) => {
249
+ const prefix = `${indent}${index === 0 ? hook : " "} `;
250
+ lines.push(formatTodoLine(todo, uiTheme, prefix));
251
+ });
252
+ }
253
+
254
+ return new Text(lines.join("\n"), 0, 0);
255
+ },
256
+ };
@@ -12,6 +12,7 @@ import { ensureTool } from "../../utils/tools-manager";
12
12
  import type { RenderResultOptions } from "../custom-tools/types";
13
13
  import { renderPromptTemplate } from "../prompt-templates";
14
14
  import type { ToolSession } from "./index";
15
+ import { formatExpandHint } from "./render-utils";
15
16
  import { specialHandlers } from "./web-scrapers/index";
16
17
  import type { RenderResult } from "./web-scrapers/types";
17
18
  import { finalizeOutput, loadPage } from "./web-scrapers/types";
@@ -947,8 +948,9 @@ export function renderWebFetchResult(
947
948
  const statusIcon = details.truncated
948
949
  ? uiTheme.styledSymbol("status.warning", "warning")
949
950
  : uiTheme.styledSymbol("status.success", "success");
950
- const expandHint = expanded ? "" : uiTheme.fg("dim", " (Ctrl+O to expand)");
951
- let text = `${statusIcon} ${uiTheme.fg("accent", `(${domain})`)}${uiTheme.sep.dot}${uiTheme.fg("dim", details.method)}${expandHint}`;
951
+ const expandHint = formatExpandHint(uiTheme, expanded);
952
+ const expandSuffix = expandHint ? ` ${expandHint}` : "";
953
+ let text = `${statusIcon} ${uiTheme.fg("accent", `(${domain})`)}${uiTheme.sep.dot}${uiTheme.fg("dim", details.method)}${expandSuffix}`;
952
954
 
953
955
  // Get content text
954
956
  const contentText = result.content[0]?.text ?? "";
@@ -1,4 +1,4 @@
1
- import { parseFrontmatter } from "../../../discovery/helpers";
1
+ import { parseFrontmatter } from "../../../core/frontmatter";
2
2
  import type { RenderResult, SpecialHandler } from "./types";
3
3
  import { finalizeOutput, loadPage } from "./types";
4
4
 
@@ -69,7 +69,7 @@ export const handleChooseALicense: SpecialHandler = async (
69
69
  const result = await loadPage(rawUrl, { timeout, headers: { Accept: "text/plain" }, signal });
70
70
  if (!result.ok) return null;
71
71
 
72
- const { frontmatter, body } = parseFrontmatter(result.content);
72
+ const { frontmatter, body } = parseFrontmatter(result.content, { source: rawUrl });
73
73
 
74
74
  const title = asString(frontmatter.title) ?? formatLabel(licenseSlug);
75
75
  const spdxId = asString(frontmatter["spdx-id"]) ?? "Unknown";
@@ -13,9 +13,9 @@ import {
13
13
  formatCount,
14
14
  formatExpandHint,
15
15
  formatMoreItems,
16
+ formatStatusIcon,
16
17
  getDomain,
17
18
  getPreviewLines,
18
- getStyledStatusIcon,
19
19
  PREVIEW_LIMITS,
20
20
  TRUNCATE_LENGTHS,
21
21
  truncate,
@@ -38,8 +38,8 @@ function renderFallbackText(contentText: string, expanded: boolean, theme: Theme
38
38
  const displayLines = lines.slice(0, maxLines).map((line) => truncate(line.trim(), 110, theme.format.ellipsis));
39
39
  const remaining = lines.length - displayLines.length;
40
40
 
41
- const headerIcon = getStyledStatusIcon("warning", theme);
42
- const expandHint = formatExpandHint(expanded, remaining > 0, theme);
41
+ const headerIcon = formatStatusIcon("warning", theme);
42
+ const expandHint = formatExpandHint(theme, expanded, remaining > 0);
43
43
  let text = `${headerIcon} ${theme.fg("dim", "Response")}${expandHint}`;
44
44
 
45
45
  if (displayLines.length === 0) {
@@ -116,14 +116,14 @@ export function renderWebSearchResult(
116
116
  : provider === "exa"
117
117
  ? "Exa"
118
118
  : "Unknown";
119
- const headerIcon = getStyledStatusIcon(sourceCount > 0 ? "success" : "warning", theme);
119
+ const headerIcon = formatStatusIcon(sourceCount > 0 ? "success" : "warning", theme);
120
120
  const hasMore =
121
121
  totalAnswerLines > answerPreview.length ||
122
122
  sourceCount > 0 ||
123
123
  citationCount > 0 ||
124
124
  relatedCount > 0 ||
125
125
  searchQueries.length > 0;
126
- const expandHint = formatExpandHint(expanded, hasMore, theme);
126
+ const expandHint = formatExpandHint(theme, expanded, hasMore);
127
127
  let text = `${headerIcon} ${theme.fg("dim", `(${providerLabel})`)}${theme.sep.dot}${theme.fg(
128
128
  "dim",
129
129
  formatCount("source", sourceCount),
@@ -257,7 +257,10 @@ export function renderWebSearchResult(
257
257
  }
258
258
  if (response.requestId) {
259
259
  metaLines.push(
260
- `${theme.fg("muted", "Request:")} ${theme.fg("text", truncate(response.requestId, MAX_REQUEST_ID_LEN, theme.format.ellipsis))}`,
260
+ `${theme.fg("muted", "Request:")} ${theme.fg(
261
+ "text",
262
+ truncate(response.requestId, MAX_REQUEST_ID_LEN, theme.format.ellipsis),
263
+ )}`,
261
264
  );
262
265
  }
263
266
  if (searchQueries.length > 0) {
@@ -274,22 +277,22 @@ export function renderWebSearchResult(
274
277
  const sections: Array<{ title: string; icon: string; lines: string[] }> = [
275
278
  {
276
279
  title: "Answer",
277
- icon: getStyledStatusIcon("info", theme),
280
+ icon: formatStatusIcon("info", theme),
278
281
  lines: answerSectionLines,
279
282
  },
280
283
  {
281
284
  title: "Sources",
282
- icon: getStyledStatusIcon(sourceCount > 0 ? "success" : "warning", theme),
285
+ icon: formatStatusIcon(sourceCount > 0 ? "success" : "warning", theme),
283
286
  lines: sourceLines,
284
287
  },
285
288
  {
286
289
  title: "Related",
287
- icon: getStyledStatusIcon(relatedCount > 0 ? "info" : "warning", theme),
290
+ icon: formatStatusIcon(relatedCount > 0 ? "info" : "warning", theme),
288
291
  lines: relatedLines,
289
292
  },
290
293
  {
291
294
  title: "Meta",
292
- icon: getStyledStatusIcon("info", theme),
295
+ icon: formatStatusIcon("info", theme),
293
296
  lines: metaLines,
294
297
  },
295
298
  ];
@@ -10,7 +10,7 @@ import type { ToolSession } from "../sdk";
10
10
  import { untilAborted } from "../utils";
11
11
  import { createLspWritethrough, type FileDiagnosticsResult, writethroughNoop } from "./lsp/index";
12
12
  import { resolveToCwd } from "./path-utils";
13
- import { formatDiagnostics, replaceTabs, shortenPath } from "./render-utils";
13
+ import { formatDiagnostics, formatExpandHint, replaceTabs, shortenPath } from "./render-utils";
14
14
 
15
15
  const writeSchema = Type.Object({
16
16
  path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
@@ -132,7 +132,7 @@ export const writeToolRenderer = {
132
132
  outputLines.push(
133
133
  uiTheme.fg(
134
134
  "toolOutput",
135
- `${uiTheme.format.ellipsis} (${remaining} more lines, ${totalLines} total) ${uiTheme.format.bracketLeft}Ctrl+O to expand${uiTheme.format.bracketRight}`,
135
+ `${uiTheme.format.ellipsis} (${remaining} more lines, ${totalLines} total) ${formatExpandHint(uiTheme)}`,
136
136
  ),
137
137
  );
138
138
  }
@@ -22,6 +22,7 @@ import { type SlashCommand, slashCommandCapability } from "../capability/slash-c
22
22
  import { type SystemPrompt, systemPromptCapability } from "../capability/system-prompt";
23
23
  import { type CustomTool, toolCapability } from "../capability/tool";
24
24
  import type { LoadContext, LoadResult } from "../capability/types";
25
+ import { parseFrontmatter } from "../core/frontmatter";
25
26
  import {
26
27
  createSourceMeta,
27
28
  discoverExtensionModulePaths,
@@ -29,7 +30,6 @@ import {
29
30
  getExtensionNameFromPath,
30
31
  loadFilesFromDir,
31
32
  loadSkillsFromDir,
32
- parseFrontmatter,
33
33
  parseJSON,
34
34
  SOURCE_PATHS,
35
35
  } from "./helpers";
@@ -266,7 +266,7 @@ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
266
266
  const result = await loadFilesFromDir<Rule>(ctx, rulesDir, PROVIDER_ID, level, {
267
267
  extensions: ["md", "mdc"],
268
268
  transform: (name, content, path, source) => {
269
- const { frontmatter, body } = parseFrontmatter(content);
269
+ const { frontmatter, body } = parseFrontmatter(content, { source: path });
270
270
  return {
271
271
  name: name.replace(/\.(md|mdc)$/, ""),
272
272
  path,
@@ -516,7 +516,7 @@ async function loadInstructions(ctx: LoadContext): Promise<LoadResult<Instructio
516
516
  const result = await loadFilesFromDir<Instruction>(ctx, instructionsDir, PROVIDER_ID, level, {
517
517
  extensions: ["md"],
518
518
  transform: (name, content, path, source) => {
519
- const { frontmatter, body } = parseFrontmatter(content);
519
+ const { frontmatter, body } = parseFrontmatter(content, { source: path });
520
520
  return {
521
521
  name: name.replace(/\.instructions\.md$/, "").replace(/\.md$/, ""),
522
522
  path,
@@ -636,7 +636,7 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
636
636
  _source: source,
637
637
  };
638
638
  }
639
- const { frontmatter } = parseFrontmatter(content);
639
+ const { frontmatter } = parseFrontmatter(content, { source: path });
640
640
  return {
641
641
  name: (frontmatter.name as string) || name.replace(/\.md$/, ""),
642
642
  path,
@@ -11,7 +11,8 @@ import { registerProvider } from "../capability/index";
11
11
  import type { Rule } from "../capability/rule";
12
12
  import { ruleCapability } from "../capability/rule";
13
13
  import type { LoadContext, LoadResult } from "../capability/types";
14
- import { createSourceMeta, loadFilesFromDir, parseFrontmatter } from "./helpers";
14
+ import { parseFrontmatter } from "../core/frontmatter";
15
+ import { createSourceMeta, loadFilesFromDir } from "./helpers";
15
16
 
16
17
  const PROVIDER_ID = "cline";
17
18
  const DISPLAY_NAME = "Cline";
@@ -54,7 +55,7 @@ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
54
55
  const result = await loadFilesFromDir(ctx, found.path, PROVIDER_ID, "project", {
55
56
  extensions: ["md"],
56
57
  transform: (name, content, path, source) => {
57
- const { frontmatter, body } = parseFrontmatter(content);
58
+ const { frontmatter, body } = parseFrontmatter(content, { source: path });
58
59
  const ruleName = name.replace(/\.md$/, "");
59
60
 
60
61
  // Parse globs (can be array or single string)
@@ -88,7 +89,7 @@ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
88
89
  return { items, warnings };
89
90
  }
90
91
 
91
- const { frontmatter, body } = parseFrontmatter(content);
92
+ const { frontmatter, body } = parseFrontmatter(content, { source: found.path });
92
93
  const source = createSourceMeta(PROVIDER_ID, found.path, "project");
93
94
 
94
95
  // Parse globs (can be array or single string)
@@ -29,6 +29,7 @@ import { slashCommandCapability } from "../capability/slash-command";
29
29
  import type { CustomTool } from "../capability/tool";
30
30
  import { toolCapability } from "../capability/tool";
31
31
  import type { LoadContext, LoadResult } from "../capability/types";
32
+ import { parseFrontmatter } from "../core/frontmatter";
32
33
  import { logger } from "../core/logger";
33
34
  import {
34
35
  createSourceMeta,
@@ -36,7 +37,6 @@ import {
36
37
  getExtensionNameFromPath,
37
38
  loadFilesFromDir,
38
39
  loadSkillsFromDir,
39
- parseFrontmatter,
40
40
  SOURCE_PATHS,
41
41
  } from "./helpers";
42
42
 
@@ -279,7 +279,7 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
279
279
  const transformCommand =
280
280
  (level: "user" | "project") =>
281
281
  (name: string, content: string, path: string, source: ReturnType<typeof createSourceMeta>) => {
282
- const { frontmatter, body } = parseFrontmatter(content);
282
+ const { frontmatter, body } = parseFrontmatter(content, { source: path });
283
283
  const commandName = frontmatter.name || name.replace(/\.md$/, "");
284
284
  return {
285
285
  name: String(commandName),
@@ -322,7 +322,7 @@ async function loadPrompts(ctx: LoadContext): Promise<LoadResult<Prompt>> {
322
322
  path: string,
323
323
  source: ReturnType<typeof createSourceMeta>,
324
324
  ) => {
325
- const { frontmatter, body } = parseFrontmatter(content);
325
+ const { frontmatter, body } = parseFrontmatter(content, { source: path });
326
326
  const promptName = frontmatter.name || name.replace(/\.md$/, "");
327
327
  return {
328
328
  name: String(promptName),
@@ -22,13 +22,13 @@ import { ruleCapability } from "../capability/rule";
22
22
  import type { Settings } from "../capability/settings";
23
23
  import { settingsCapability } from "../capability/settings";
24
24
  import type { LoadContext, LoadResult } from "../capability/types";
25
+ import { parseFrontmatter } from "../core/frontmatter";
25
26
  import {
26
27
  createSourceMeta,
27
28
  expandEnvVarsDeep,
28
29
  getProjectPath,
29
30
  getUserPath,
30
31
  loadFilesFromDir,
31
- parseFrontmatter,
32
32
  parseJSON,
33
33
  } from "./helpers";
34
34
 
@@ -143,7 +143,7 @@ function transformMDCRule(
143
143
  path: string,
144
144
  source: ReturnType<typeof createSourceMeta>,
145
145
  ): Rule {
146
- const { frontmatter, body } = parseFrontmatter(content);
146
+ const { frontmatter, body } = parseFrontmatter(content, { source: path });
147
147
 
148
148
  // Extract frontmatter fields
149
149
  const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined;
@@ -18,7 +18,8 @@ import { readFile } from "../capability/fs";
18
18
  import { registerProvider } from "../capability/index";
19
19
  import { type Instruction, instructionCapability } from "../capability/instruction";
20
20
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
21
- import { calculateDepth, createSourceMeta, getProjectPath, loadFilesFromDir, parseFrontmatter } from "./helpers";
21
+ import { parseFrontmatter } from "../core/frontmatter";
22
+ import { calculateDepth, createSourceMeta, getProjectPath, loadFilesFromDir } from "./helpers";
22
23
 
23
24
  const PROVIDER_ID = "github";
24
25
  const DISPLAY_NAME = "GitHub Copilot";
@@ -79,7 +80,7 @@ function transformInstruction(name: string, content: string, path: string, sourc
79
80
  return null;
80
81
  }
81
82
 
82
- const { frontmatter, body } = parseFrontmatter(content);
83
+ const { frontmatter, body } = parseFrontmatter(content, { source: path });
83
84
 
84
85
  // Extract applyTo glob pattern from frontmatter
85
86
  const applyTo = typeof frontmatter.applyTo === "string" ? frontmatter.applyTo : undefined;
@@ -1,7 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { parseFrontmatter } from "./helpers";
2
+ import { parseFrontmatter } from "../core/frontmatter";
3
3
 
4
4
  describe("parseFrontmatter", () => {
5
+ const parse = (content: string) => parseFrontmatter(content, { source: "tests:frontmatter", level: "off" });
6
+
5
7
  test("parses simple key-value pairs", () => {
6
8
  const content = `---
7
9
  name: test
@@ -9,7 +11,7 @@ enabled: true
9
11
  ---
10
12
  Body content`;
11
13
 
12
- const result = parseFrontmatter(content);
14
+ const result = parse(content);
13
15
  expect(result.frontmatter).toEqual({ name: "test", enabled: true });
14
16
  expect(result.body).toBe("Body content");
15
17
  });
@@ -23,7 +25,7 @@ tags:
23
25
  ---
24
26
  Body content`;
25
27
 
26
- const result = parseFrontmatter(content);
28
+ const result = parse(content);
27
29
  expect(result.frontmatter).toEqual({
28
30
  tags: ["javascript", "typescript", "react"],
29
31
  });
@@ -39,7 +41,7 @@ description: |
39
41
  ---
40
42
  Body content`;
41
43
 
42
- const result = parseFrontmatter(content);
44
+ const result = parse(content);
43
45
  expect(result.frontmatter).toEqual({
44
46
  description: "This is a multi-line\ndescription block\nwith several lines\n",
45
47
  });
@@ -57,7 +59,7 @@ config:
57
59
  ---
58
60
  Body content`;
59
61
 
60
- const result = parseFrontmatter(content);
62
+ const result = parse(content);
61
63
  expect(result.frontmatter).toEqual({
62
64
  config: {
63
65
  server: { port: 3000, host: "localhost" },
@@ -83,7 +85,7 @@ description: |
83
85
  ---
84
86
  Body content`;
85
87
 
86
- const result = parseFrontmatter(content);
88
+ const result = parse(content);
87
89
  expect(result.frontmatter).toEqual({
88
90
  name: "complex-test",
89
91
  version: "1.0.0",
@@ -99,7 +101,7 @@ Body content`;
99
101
 
100
102
  test("handles missing frontmatter", () => {
101
103
  const content = "Just body content";
102
- const result = parseFrontmatter(content);
104
+ const result = parse(content);
103
105
  expect(result.frontmatter).toEqual({});
104
106
  expect(result.body).toBe("Just body content");
105
107
  });
@@ -110,8 +112,10 @@ invalid: [unclosed array
110
112
  ---
111
113
  Body content`;
112
114
 
113
- const result = parseFrontmatter(content);
114
- expect(result.frontmatter).toEqual({}); // Fallback to empty
115
+ const result = parse(content);
116
+ // Simple fallback parser extracts key:value pairs it can parse
117
+ expect(result.frontmatter).toEqual({ invalid: "[unclosed array" });
118
+ // Body is still extracted even with invalid YAML
115
119
  expect(result.body).toBe("Body content");
116
120
  });
117
121
 
@@ -120,7 +124,7 @@ Body content`;
120
124
  ---
121
125
  Body content`;
122
126
 
123
- const result = parseFrontmatter(content);
127
+ const result = parse(content);
124
128
  expect(result.frontmatter).toEqual({});
125
129
  expect(result.body).toBe("Body content");
126
130
  });