magus-cli 7.3.0 → 7.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/scripts/build-binaries.ts +4 -2
- package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/npm-package-name-api.test.ts +111 -0
- package/src/__tests__/npm-package-name.test.ts +280 -0
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/upgrade-spawn.test.ts +355 -0
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +105 -14
- package/src/cli/install.ts +8 -2
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/cli/upgrade.ts +37 -9
- package/src/data/cli-tools.ts +13 -2
- package/src/data/npm-package.ts +15 -0
- package/src/services/claude-settings.ts +151 -73
- package/src/services/cli-tool-commands.ts +15 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +158 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +272 -45
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/services/version-check.ts +20 -10
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +99 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +180 -0
- package/src/ui/input/key-dispatch.ts +149 -0
- package/src/ui/keymap.ts +898 -0
- package/src/ui/renderers/cliToolRenderers.tsx +128 -66
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +98 -87
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +123 -127
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- package/src/ui/hooks/useKeyboardHandler.ts +0 -63
|
@@ -13,15 +13,162 @@
|
|
|
13
13
|
* mid-edit. Treating it as `{}` and writing our one key back is how a
|
|
14
14
|
* user's whole configuration disappears. We refuse and say so.
|
|
15
15
|
*
|
|
16
|
+
* "Unparseable" includes a file that parses into something other than an
|
|
17
|
+
* object — `[]`, `null`, `5`, `"text"`. Every caller indexes the result as a
|
|
18
|
+
* map, so an array swallowed an enabled plugin as a property `JSON.stringify`
|
|
19
|
+
* never writes (the enable reported success and was gone), and `null` or a
|
|
20
|
+
* number crashed with a raw `TypeError`. Neither is a file we understand.
|
|
21
|
+
*
|
|
16
22
|
* A MISSING file is different from an unparseable one: nothing is lost by
|
|
17
23
|
* starting from `{}`, so that is what a missing (or empty) file reads as.
|
|
24
|
+
*
|
|
25
|
+
* Every Claude Code settings reader goes through `readSettingsFile` —
|
|
26
|
+
* `claude-settings.ts` delegates its four readers here, and the profiles
|
|
27
|
+
* readers in `manifest.ts` build on it — so there is one refusal, one wording
|
|
28
|
+
* and one error class for all of them.
|
|
18
29
|
*/
|
|
19
30
|
|
|
20
31
|
import path from "node:path";
|
|
21
32
|
import fs from "fs-extra";
|
|
22
33
|
|
|
23
34
|
/**
|
|
24
|
-
*
|
|
35
|
+
* A settings file that exists and cannot be used: it does not parse, or it
|
|
36
|
+
* parses into the wrong shape (see {@link SettingsShapeError}).
|
|
37
|
+
*
|
|
38
|
+
* Its own class so a caller can tell "this file is damaged" apart from every
|
|
39
|
+
* other failure and name the file to the user. The message is complete on its
|
|
40
|
+
* own — the path, what is wrong, and that magus refused — so a caller that
|
|
41
|
+
* prints `message` needs nothing else. `problem` and `detail` are its parts,
|
|
42
|
+
* for a caller that lays the file out on its own line instead.
|
|
43
|
+
*/
|
|
44
|
+
export class SettingsParseError extends Error {
|
|
45
|
+
readonly path: string;
|
|
46
|
+
/** What is wrong, as the rest of a sentence that starts with the file: "is not valid JSON". */
|
|
47
|
+
readonly problem: string;
|
|
48
|
+
/** The specifics: the parser's own words, or which part has the wrong shape. */
|
|
49
|
+
readonly detail: string;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
settingsPath: string,
|
|
53
|
+
cause: unknown,
|
|
54
|
+
problem = "is not valid JSON",
|
|
55
|
+
) {
|
|
56
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
57
|
+
super(`${settingsPath} ${problem}, refusing to overwrite it: ${detail}`, {
|
|
58
|
+
cause,
|
|
59
|
+
});
|
|
60
|
+
this.name = "SettingsParseError";
|
|
61
|
+
this.path = settingsPath;
|
|
62
|
+
this.problem = problem;
|
|
63
|
+
this.detail = detail;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A settings or profiles file that is valid JSON, but not the shape the file
|
|
69
|
+
* must have — a top level that is not an object, or (for a profiles file) a
|
|
70
|
+
* `profiles` key that is not a map of objects.
|
|
71
|
+
*
|
|
72
|
+
* Refused exactly like a file that does not parse, and for the same reason:
|
|
73
|
+
* reading it as "empty" is what lets the next write replace it. A subclass so
|
|
74
|
+
* every existing `instanceof SettingsParseError` catch handles it unchanged.
|
|
75
|
+
*/
|
|
76
|
+
export class SettingsShapeError extends SettingsParseError {
|
|
77
|
+
constructor(settingsPath: string, problem: string, detail: string) {
|
|
78
|
+
super(settingsPath, detail, problem);
|
|
79
|
+
this.name = "SettingsShapeError";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True for a JSON object: not `null`, not an array, not a primitive. */
|
|
84
|
+
export function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
85
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A parsed JSON value's kind with its article, for a refusal: "an array", "null". */
|
|
89
|
+
export function describeJsonKind(value: unknown): string {
|
|
90
|
+
if (value === null) return "null";
|
|
91
|
+
if (Array.isArray(value)) return "an array";
|
|
92
|
+
if (typeof value === "object") return "an object";
|
|
93
|
+
return `a ${typeof value}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The map at `settings[key]`, checked before the caller writes an entry into
|
|
98
|
+
* it: the map itself, or `undefined` when the key is absent. Present but not a
|
|
99
|
+
* JSON object THROWS {@link SettingsShapeError} naming the file and the key —
|
|
100
|
+
* so nothing reaches the write, and the file stays byte-for-byte as it was.
|
|
101
|
+
*
|
|
102
|
+
* The top-level check in {@link readSettingsFile} does not reach this far: the
|
|
103
|
+
* file IS an object, one of its maps is not. `settings.enabledPlugins || {}`
|
|
104
|
+
* kept an array, a plugin id set on an array is a property `JSON.stringify`
|
|
105
|
+
* never writes, and the enable reported success with nothing saved. On a
|
|
106
|
+
* string or a number the same assignment is a raw `TypeError`; on `null`,
|
|
107
|
+
* `|| {}` silently replaced the user's value.
|
|
108
|
+
*
|
|
109
|
+
* Every read-modify-write that writes into a map comes through here.
|
|
110
|
+
* `keyPath` names a nested key in the refusal ("permissions.allow"); it
|
|
111
|
+
* defaults to `key`.
|
|
112
|
+
*/
|
|
113
|
+
export function writableMap<S extends object, K extends keyof S & string>(
|
|
114
|
+
settings: S,
|
|
115
|
+
key: K,
|
|
116
|
+
settingsPath: string,
|
|
117
|
+
keyPath: string = key,
|
|
118
|
+
): CheckedMap<S[K]> {
|
|
119
|
+
return checkedEntry(
|
|
120
|
+
settings,
|
|
121
|
+
key,
|
|
122
|
+
settingsPath,
|
|
123
|
+
keyPath,
|
|
124
|
+
"object",
|
|
125
|
+
) as CheckedMap<S[K]>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A typed key keeps its type; an untyped one (`unknown`, from an open settings
|
|
130
|
+
* record) comes back as the object the check has just proved it is.
|
|
131
|
+
*/
|
|
132
|
+
type CheckedMap<V> = unknown extends V
|
|
133
|
+
? Record<string, unknown> | undefined
|
|
134
|
+
: V;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* {@link writableMap} for a list the caller pushes into or filters —
|
|
138
|
+
* `enabledMcpjsonServers`. Present but not an array throws the same way.
|
|
139
|
+
*/
|
|
140
|
+
export function writableList<S extends object, K extends keyof S & string>(
|
|
141
|
+
settings: S,
|
|
142
|
+
key: K,
|
|
143
|
+
settingsPath: string,
|
|
144
|
+
): S[K] {
|
|
145
|
+
return checkedEntry(settings, key, settingsPath, key, "array");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function checkedEntry<S extends object, K extends keyof S & string>(
|
|
149
|
+
settings: S,
|
|
150
|
+
key: K,
|
|
151
|
+
settingsPath: string,
|
|
152
|
+
keyPath: string,
|
|
153
|
+
shape: "object" | "array",
|
|
154
|
+
): S[K] {
|
|
155
|
+
const value = settings[key];
|
|
156
|
+
if (value === undefined) return value;
|
|
157
|
+
const fits = shape === "object" ? isJsonObject(value) : Array.isArray(value);
|
|
158
|
+
if (!fits) {
|
|
159
|
+
throw new SettingsShapeError(
|
|
160
|
+
settingsPath,
|
|
161
|
+
`has a malformed "${keyPath}"`,
|
|
162
|
+
`it is ${describeJsonKind(value)}, not a JSON ${shape}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse `settingsPath`. Missing or empty reads as `{}`; unparseable throws
|
|
170
|
+
* {@link SettingsParseError}, and a top level that is not an object throws
|
|
171
|
+
* {@link SettingsShapeError}.
|
|
25
172
|
*
|
|
26
173
|
* Exported for callers that only need the read half — the refusal wording is
|
|
27
174
|
* the same either way, so nothing has to reimplement it.
|
|
@@ -34,13 +181,20 @@ export async function readSettingsFile(
|
|
|
34
181
|
const raw = (await fs.readFile(settingsPath, "utf8")).trim();
|
|
35
182
|
if (!raw) return {};
|
|
36
183
|
|
|
184
|
+
let parsed: unknown;
|
|
37
185
|
try {
|
|
38
|
-
|
|
186
|
+
parsed = JSON.parse(raw);
|
|
39
187
|
} catch (error) {
|
|
40
|
-
throw new
|
|
41
|
-
|
|
188
|
+
throw new SettingsParseError(settingsPath, error);
|
|
189
|
+
}
|
|
190
|
+
if (!isJsonObject(parsed)) {
|
|
191
|
+
throw new SettingsShapeError(
|
|
192
|
+
settingsPath,
|
|
193
|
+
"is not a JSON object",
|
|
194
|
+
`its top level is ${describeJsonKind(parsed)}`,
|
|
42
195
|
);
|
|
43
196
|
}
|
|
197
|
+
return parsed;
|
|
44
198
|
}
|
|
45
199
|
|
|
46
200
|
/**
|
|
@@ -3,11 +3,14 @@ import * as path from "node:path";
|
|
|
3
3
|
import type { SettingDefinition } from "../data/settings-catalog.js";
|
|
4
4
|
import type { ClaudeSettings } from "../types/index.js";
|
|
5
5
|
import {
|
|
6
|
+
getGlobalSettingsPath,
|
|
7
|
+
getSettingsPath,
|
|
6
8
|
readGlobalSettings,
|
|
7
9
|
readSettings,
|
|
8
10
|
writeGlobalSettings,
|
|
9
11
|
writeSettings,
|
|
10
12
|
} from "./claude-settings.js";
|
|
13
|
+
import { writableMap } from "./settings-file.js";
|
|
11
14
|
|
|
12
15
|
export type SettingScope = "user" | "project";
|
|
13
16
|
|
|
@@ -103,6 +106,9 @@ export async function writeSettingValue(
|
|
|
103
106
|
projectPath?: string,
|
|
104
107
|
): Promise<void> {
|
|
105
108
|
const settings = await loadSettings(scope, projectPath);
|
|
109
|
+
// Named in the refusal when a map this writes into has the wrong shape.
|
|
110
|
+
const settingsPath =
|
|
111
|
+
scope === "user" ? getGlobalSettingsPath() : getSettingsPath(projectPath);
|
|
106
112
|
|
|
107
113
|
if (setting.storage.type === "attribution") {
|
|
108
114
|
// Boolean toggle: "false" -> write { commit: "", pr: "" }, "true"/undefined -> delete key
|
|
@@ -125,8 +131,10 @@ export async function writeSettingValue(
|
|
|
125
131
|
delete settings.attribution;
|
|
126
132
|
}
|
|
127
133
|
} else if (setting.storage.type === "env") {
|
|
128
|
-
// Write to the "env" block in settings.json
|
|
129
|
-
|
|
134
|
+
// Write to the "env" block in settings.json. One that is present but not
|
|
135
|
+
// an object is refused, never replaced — replacing it deleted the user's
|
|
136
|
+
// value to make room for ours.
|
|
137
|
+
const env = writableMap(settings, "env", settingsPath) ?? {};
|
|
130
138
|
settings.env = env;
|
|
131
139
|
if (value === undefined || value === "" || value === setting.defaultValue) {
|
|
132
140
|
delete env[setting.storage.key];
|
|
@@ -149,11 +157,17 @@ export async function writeSettingValue(
|
|
|
149
157
|
settings[keys[0]] = parseSettingValue(value, setting.type);
|
|
150
158
|
}
|
|
151
159
|
} else {
|
|
152
|
-
// Nested key like "permissions.defaultMode"
|
|
160
|
+
// Nested key like "permissions.defaultMode". Each parent on the way is
|
|
161
|
+
// a map this writes into: absent is created, the wrong shape refused.
|
|
153
162
|
let obj: Record<string, unknown> = settings;
|
|
154
163
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
155
|
-
const
|
|
156
|
-
|
|
164
|
+
const next =
|
|
165
|
+
writableMap(
|
|
166
|
+
obj,
|
|
167
|
+
keys[i],
|
|
168
|
+
settingsPath,
|
|
169
|
+
keys.slice(0, i + 1).join("."),
|
|
170
|
+
) ?? {};
|
|
157
171
|
obj[keys[i]] = next;
|
|
158
172
|
obj = next;
|
|
159
173
|
}
|
|
@@ -664,6 +664,26 @@ function parseYamlFrontmatter(content: string): Partial<SkillFrontmatter> {
|
|
|
664
664
|
if (!frontmatterMatch) return {};
|
|
665
665
|
|
|
666
666
|
const yaml = frontmatterMatch[1];
|
|
667
|
+
|
|
668
|
+
// Real YAML first: a description is routinely a block scalar (`description: >-`
|
|
669
|
+
// with the text on the lines below), which the line reading further down returns
|
|
670
|
+
// as the literal ">-". Many community SKILL.md files are not valid YAML, though —
|
|
671
|
+
// an unquoted ": " inside a description is common — so a parse failure falls
|
|
672
|
+
// back to the line reading those files have always had.
|
|
673
|
+
try {
|
|
674
|
+
// `Bun.YAML` ships in the runtime (1.2.21+) but not in the Bun types this
|
|
675
|
+
// package resolves. A runtime without it throws here and takes the fallback.
|
|
676
|
+
const { YAML } = Bun as unknown as {
|
|
677
|
+
YAML: { parse(text: string): unknown };
|
|
678
|
+
};
|
|
679
|
+
const parsed: unknown = YAML.parse(yaml);
|
|
680
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
681
|
+
return parsed as Partial<SkillFrontmatter>;
|
|
682
|
+
}
|
|
683
|
+
} catch {
|
|
684
|
+
// Not valid YAML — read it line by line below.
|
|
685
|
+
}
|
|
686
|
+
|
|
667
687
|
const result: Record<string, unknown> = {};
|
|
668
688
|
|
|
669
689
|
for (const line of yaml.split("\n")) {
|
|
@@ -692,6 +712,32 @@ function parseYamlFrontmatter(content: string): Partial<SkillFrontmatter> {
|
|
|
692
712
|
return result as Partial<SkillFrontmatter>;
|
|
693
713
|
}
|
|
694
714
|
|
|
715
|
+
/**
|
|
716
|
+
* The frontmatter of an INSTALLED skill, read from its SKILL.md on disk.
|
|
717
|
+
*
|
|
718
|
+
* A local skill has no remote to fetch from, so this is the only place its
|
|
719
|
+
* description can come from. `name` falls back to the directory name and
|
|
720
|
+
* `description` to "" — the caller decides how to say "none". Never throws: an
|
|
721
|
+
* unreadable file answers `null`.
|
|
722
|
+
*/
|
|
723
|
+
export async function readLocalSkillFrontmatter(
|
|
724
|
+
skillMdPath: string,
|
|
725
|
+
fallbackName: string,
|
|
726
|
+
): Promise<SkillFrontmatter | null> {
|
|
727
|
+
let content: string;
|
|
728
|
+
try {
|
|
729
|
+
content = await fs.readFile(skillMdPath, "utf8");
|
|
730
|
+
} catch {
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
const parsed = parseYamlFrontmatter(content);
|
|
734
|
+
return {
|
|
735
|
+
...parsed,
|
|
736
|
+
name: parsed.name || fallbackName,
|
|
737
|
+
description: parsed.description || "",
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
695
741
|
export async function fetchSkillFrontmatter(
|
|
696
742
|
skill: SkillInfo,
|
|
697
743
|
): Promise<SkillFrontmatter> {
|