@saadnvd1/agent-os 0.3.756 → 0.3.758
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/components/Chat/ComposerMenu.tsx +71 -31
- package/components/Chat/composer/ComposerBody.tsx +29 -5
- package/lib/chat/commands.ts +72 -2
- package/lib/chat/events.ts +6 -0
- package/lib/chat/recent-commands.ts +28 -0
- package/lib/chat/settings.ts +2 -0
- package/lib/chat/skill-source.ts +95 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, type ReactNode } from "react";
|
|
3
|
+
import { Fragment, useEffect, useRef, type ReactNode } from "react";
|
|
4
4
|
import { RefreshCw } from "lucide-react";
|
|
5
5
|
import { cn } from "@/lib/utils";
|
|
6
6
|
|
|
@@ -10,6 +10,25 @@ export interface MenuOption {
|
|
|
10
10
|
icon?: ReactNode;
|
|
11
11
|
hint?: string;
|
|
12
12
|
description?: string;
|
|
13
|
+
// A section heading shown above this option when it starts a new section.
|
|
14
|
+
group?: string;
|
|
15
|
+
// A small label on the row (where a command comes from).
|
|
16
|
+
badge?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The part of `text` that matches what was typed, picked out.
|
|
20
|
+
function Marked({ text, query }: { text: string; query?: string }) {
|
|
21
|
+
const at = query ? text.toLowerCase().indexOf(query) : -1;
|
|
22
|
+
if (at < 0 || !query) return <>{text}</>;
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
{text.slice(0, at)}
|
|
26
|
+
<mark className="text-primary bg-transparent font-semibold">
|
|
27
|
+
{text.slice(at, at + query.length)}
|
|
28
|
+
</mark>
|
|
29
|
+
{text.slice(at + query.length)}
|
|
30
|
+
</>
|
|
31
|
+
);
|
|
13
32
|
}
|
|
14
33
|
|
|
15
34
|
// The menu above the composer: "/" commands and skills, "@" files and
|
|
@@ -22,6 +41,7 @@ export function ComposerMenu({
|
|
|
22
41
|
onPick,
|
|
23
42
|
onHover,
|
|
24
43
|
refresh,
|
|
44
|
+
highlight,
|
|
25
45
|
}: {
|
|
26
46
|
label: string;
|
|
27
47
|
empty: string;
|
|
@@ -31,6 +51,7 @@ export function ComposerMenu({
|
|
|
31
51
|
onHover: (index: number) => void;
|
|
32
52
|
// Reloads the list (a skill added since it was read).
|
|
33
53
|
refresh?: { onClick: () => void; busy: boolean };
|
|
54
|
+
highlight?: string;
|
|
34
55
|
}) {
|
|
35
56
|
const listRef = useRef<HTMLDivElement>(null);
|
|
36
57
|
|
|
@@ -69,40 +90,59 @@ export function ComposerMenu({
|
|
|
69
90
|
<p className="text-muted-foreground px-2.5 py-2 text-sm">{empty}</p>
|
|
70
91
|
) : (
|
|
71
92
|
options.map((o, i) => (
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
data-index={i}
|
|
78
|
-
onMouseEnter={() => onHover(i)}
|
|
79
|
-
// Keep focus in the textarea so the keyboard stays up on phones.
|
|
80
|
-
onMouseDown={(e) => e.preventDefault()}
|
|
81
|
-
onClick={() => onPick(i)}
|
|
82
|
-
className={cn(
|
|
83
|
-
"flex min-h-11 w-full flex-col items-start justify-center rounded-lg px-2.5 py-1.5 text-left md:min-h-9",
|
|
84
|
-
i === active
|
|
85
|
-
? "bg-foreground/[0.07]"
|
|
86
|
-
: "hover:bg-foreground/[0.04]"
|
|
93
|
+
<Fragment key={o.key}>
|
|
94
|
+
{o.group && o.group !== options[i - 1]?.group && (
|
|
95
|
+
<div className="text-muted-foreground px-2.5 pt-2 pb-0.5 text-[11px] tracking-wide uppercase">
|
|
96
|
+
{o.group}
|
|
97
|
+
</div>
|
|
87
98
|
)}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
role="option"
|
|
102
|
+
aria-selected={i === active}
|
|
103
|
+
data-index={i}
|
|
104
|
+
onMouseEnter={() => onHover(i)}
|
|
105
|
+
// Keep focus in the textarea so the keyboard stays up on phones.
|
|
106
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
107
|
+
onClick={() => onPick(i)}
|
|
108
|
+
className={cn(
|
|
109
|
+
"flex min-h-11 w-full flex-col items-start justify-center rounded-lg px-2.5 py-1.5 text-left md:min-h-9",
|
|
110
|
+
i === active
|
|
111
|
+
? "bg-foreground/[0.07]"
|
|
112
|
+
: "hover:bg-foreground/[0.04]"
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
<span className="flex w-full min-w-0 items-center gap-2">
|
|
116
|
+
{o.icon}
|
|
117
|
+
<span className="max-w-[65%] shrink-0 truncate font-mono text-sm">
|
|
118
|
+
{o.icon ? (
|
|
119
|
+
o.label
|
|
120
|
+
) : (
|
|
121
|
+
<Marked text={o.label} query={highlight} />
|
|
122
|
+
)}
|
|
123
|
+
</span>
|
|
124
|
+
{o.hint && (
|
|
125
|
+
<span className="text-muted-foreground/70 min-w-0 truncate font-mono text-xs">
|
|
126
|
+
{o.hint}
|
|
127
|
+
</span>
|
|
128
|
+
)}
|
|
129
|
+
{o.badge && (
|
|
130
|
+
<span className="text-muted-foreground border-border ml-auto shrink-0 rounded-full border px-2 text-[10px]">
|
|
131
|
+
{o.badge}
|
|
132
|
+
</span>
|
|
133
|
+
)}
|
|
93
134
|
</span>
|
|
94
|
-
{o.
|
|
95
|
-
<span className="text-muted-foreground
|
|
96
|
-
{o.
|
|
135
|
+
{o.description && (
|
|
136
|
+
<span className="text-muted-foreground line-clamp-2 w-full text-xs">
|
|
137
|
+
{o.icon ? (
|
|
138
|
+
o.description
|
|
139
|
+
) : (
|
|
140
|
+
<Marked text={o.description} query={highlight} />
|
|
141
|
+
)}
|
|
97
142
|
</span>
|
|
98
143
|
)}
|
|
99
|
-
</
|
|
100
|
-
|
|
101
|
-
<span className="text-muted-foreground w-full truncate text-xs">
|
|
102
|
-
{o.description}
|
|
103
|
-
</span>
|
|
104
|
-
)}
|
|
105
|
-
</button>
|
|
144
|
+
</button>
|
|
145
|
+
</Fragment>
|
|
106
146
|
))
|
|
107
147
|
)}
|
|
108
148
|
</div>
|
|
@@ -16,7 +16,14 @@ import {
|
|
|
16
16
|
composeMessage,
|
|
17
17
|
type TextAttachment,
|
|
18
18
|
} from "@/lib/chat/paste";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
groupCommands,
|
|
21
|
+
insertCommand,
|
|
22
|
+
leadingCommand,
|
|
23
|
+
slashQuery,
|
|
24
|
+
sourceLabel,
|
|
25
|
+
} from "@/lib/chat/commands";
|
|
26
|
+
import { loadRecent, saveRecent } from "@/lib/chat/recent-commands";
|
|
20
27
|
import { mentionOption } from "@/lib/chat/mentions";
|
|
21
28
|
import { cn } from "@/lib/utils";
|
|
22
29
|
import { loadDraft, useSaveDraft } from "../useDraft";
|
|
@@ -184,25 +191,38 @@ export function ComposerBody({
|
|
|
184
191
|
const slash = slashQuery(caret.before);
|
|
185
192
|
const query = slash?.query ?? null;
|
|
186
193
|
const mentions = useMentions(editor, caret, query === null, requestFiles);
|
|
187
|
-
|
|
194
|
+
// Only read when the menu is open, so rendering on the server is harmless.
|
|
195
|
+
const [recent, setRecent] = useState(loadRecent);
|
|
196
|
+
const groups = useMemo(() => {
|
|
188
197
|
if (query === null) return [];
|
|
189
198
|
const all = [
|
|
190
199
|
...(onSetModel ? [MODEL_COMMAND] : []),
|
|
191
200
|
...commands.filter((c) => c.name !== "model"),
|
|
192
201
|
];
|
|
193
|
-
return
|
|
194
|
-
}, [query, commands, onSetModel]);
|
|
202
|
+
return groupCommands(query, all, recent);
|
|
203
|
+
}, [query, commands, onSetModel, recent]);
|
|
204
|
+
const rows = useMemo(
|
|
205
|
+
() => groups.flatMap((g) => g.commands.map((command) => ({ g, command }))),
|
|
206
|
+
[groups]
|
|
207
|
+
);
|
|
208
|
+
const matches = useMemo(() => rows.map((r) => r.command), [rows]);
|
|
195
209
|
// One menu at a time: "/" commands or "@" files at the caret.
|
|
196
210
|
const menuKey =
|
|
197
211
|
query !== null ? `/${caret.pos}:${caret.before}` : mentions.key;
|
|
198
212
|
const menuOpen = menuKey !== null && dismissed !== menuKey;
|
|
199
213
|
const options: MenuOption[] =
|
|
200
214
|
query !== null
|
|
201
|
-
?
|
|
215
|
+
? rows.map(({ g, command: c }, i) => ({
|
|
202
216
|
key: `${c.name}-${i}`,
|
|
203
217
|
label: `/${c.name}`,
|
|
204
218
|
hint: c.argumentHint,
|
|
205
219
|
description: c.description,
|
|
220
|
+
group: g.label || undefined,
|
|
221
|
+
// The heading already says it, except where a section mixes sources.
|
|
222
|
+
badge:
|
|
223
|
+
g.label === "Recent" || g.label === "Matched in description"
|
|
224
|
+
? (sourceLabel(c) ?? undefined)
|
|
225
|
+
: undefined,
|
|
206
226
|
}))
|
|
207
227
|
: mentions.files.map((f) => ({
|
|
208
228
|
key: `${f.dir ? "d" : "f"}:${f.path}`,
|
|
@@ -251,6 +271,9 @@ export function ComposerBody({
|
|
|
251
271
|
!text.trim() && !images.length && !files.length && !attach.uploads.length;
|
|
252
272
|
const submit = () => {
|
|
253
273
|
if (empty || disabled || attach.uploading) return;
|
|
274
|
+
const used = leadingCommand(text)?.slice(1);
|
|
275
|
+
if (used && commands.some((c) => c.name === used))
|
|
276
|
+
setRecent(saveRecent(used));
|
|
254
277
|
onSend(composeMessage(text, files), images, attached);
|
|
255
278
|
setImages([]);
|
|
256
279
|
setFiles([]);
|
|
@@ -308,6 +331,7 @@ export function ComposerBody({
|
|
|
308
331
|
label={query !== null ? "Commands" : "Files"}
|
|
309
332
|
empty={query !== null ? "No matching commands" : "No matching files"}
|
|
310
333
|
options={options}
|
|
334
|
+
highlight={query ?? undefined}
|
|
311
335
|
active={highlighted}
|
|
312
336
|
onPick={pick}
|
|
313
337
|
onHover={setActive}
|
package/lib/chat/commands.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChatCommand } from "./events";
|
|
1
|
+
import type { ChatCommand, CommandSource } from "./events";
|
|
2
2
|
|
|
3
3
|
// The /command being typed: a "/" at the start of a line or after whitespace,
|
|
4
4
|
// then the partial name, right up to the caret. Not "a/b" or "http://x".
|
|
@@ -26,10 +26,15 @@ function score(q: string, c: ChatCommand): number {
|
|
|
26
26
|
if (parts.some((p) => p.startsWith(q))) return 60;
|
|
27
27
|
if (name.includes(q)) return 50;
|
|
28
28
|
if (isSubsequence(q, name)) return 20;
|
|
29
|
-
|
|
29
|
+
const desc = c.description.toLowerCase();
|
|
30
|
+
if (desc.split(/[^a-z0-9]+/).some((w) => w.startsWith(q))) return 12;
|
|
31
|
+
if (desc.includes(q)) return 10;
|
|
30
32
|
return 0;
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
// A match that only came from the description, not the name.
|
|
36
|
+
const DESCRIPTION_ONLY = 12;
|
|
37
|
+
|
|
33
38
|
// Best matches first; ties keep the agent's own order.
|
|
34
39
|
export function rankCommands(
|
|
35
40
|
query: string,
|
|
@@ -68,3 +73,68 @@ export function withFresh(commands: ChatCommand[]): ChatCommand[] {
|
|
|
68
73
|
? commands
|
|
69
74
|
: [FRESH_COMMAND, ...commands];
|
|
70
75
|
}
|
|
76
|
+
|
|
77
|
+
export interface MenuGroup {
|
|
78
|
+
label: string;
|
|
79
|
+
commands: ChatCommand[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const SOURCE_ORDER: CommandSource[] = [
|
|
83
|
+
"project",
|
|
84
|
+
"global",
|
|
85
|
+
"plugin",
|
|
86
|
+
"builtin",
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
export const SOURCE_LABEL: Record<CommandSource, string> = {
|
|
90
|
+
project: "Project",
|
|
91
|
+
global: "Global",
|
|
92
|
+
plugin: "Plugin",
|
|
93
|
+
builtin: "Built-in",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// "Global" / "Project" / "slack": the small label on a row.
|
|
97
|
+
export function sourceLabel(c: ChatCommand): string | null {
|
|
98
|
+
if (!c.source) return null;
|
|
99
|
+
return c.source === "plugin" && c.plugin ? c.plugin : SOURCE_LABEL[c.source];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The ranked commands as the menu shows them: what was used last, then one
|
|
103
|
+
// section per source, then what only matched by description. A command with
|
|
104
|
+
// no known source (an agent that doesn't report one) stays in one flat list.
|
|
105
|
+
export function groupCommands(
|
|
106
|
+
query: string,
|
|
107
|
+
commands: ChatCommand[],
|
|
108
|
+
recent: string[]
|
|
109
|
+
): MenuGroup[] {
|
|
110
|
+
const ranked = rankCommands(query, commands);
|
|
111
|
+
if (!commands.some((c) => c.source)) {
|
|
112
|
+
return ranked.length ? [{ label: "", commands: ranked }] : [];
|
|
113
|
+
}
|
|
114
|
+
const groups: MenuGroup[] = [];
|
|
115
|
+
const add = (label: string, list: ChatCommand[]) => {
|
|
116
|
+
if (list.length) groups.push({ label, commands: list });
|
|
117
|
+
};
|
|
118
|
+
const rest = (c: ChatCommand) => !recent.includes(c.name);
|
|
119
|
+
const used = recent
|
|
120
|
+
.map((n) => ranked.find((c) => c.name === n))
|
|
121
|
+
.filter((c): c is ChatCommand => !!c);
|
|
122
|
+
add("Recent", used);
|
|
123
|
+
const byName = (a: ChatCommand, b: ChatCommand) =>
|
|
124
|
+
a.name.localeCompare(b.name);
|
|
125
|
+
const named = ranked.filter(
|
|
126
|
+
(c) => rest(c) && (!query || score(query, c) > DESCRIPTION_ONLY)
|
|
127
|
+
);
|
|
128
|
+
for (const src of SOURCE_ORDER) {
|
|
129
|
+
const list = named.filter((c) => (c.source ?? "builtin") === src);
|
|
130
|
+
// Alphabetical when nothing was typed; best match first otherwise.
|
|
131
|
+
add(SOURCE_LABEL[src], query ? list : [...list].sort(byName));
|
|
132
|
+
}
|
|
133
|
+
add(
|
|
134
|
+
"Matched in description",
|
|
135
|
+
ranked.filter(
|
|
136
|
+
(c) => rest(c) && query && score(query, c) <= DESCRIPTION_ONLY
|
|
137
|
+
)
|
|
138
|
+
);
|
|
139
|
+
return groups;
|
|
140
|
+
}
|
package/lib/chat/events.ts
CHANGED
|
@@ -21,8 +21,14 @@ export interface ChatCommand {
|
|
|
21
21
|
description: string;
|
|
22
22
|
argumentHint?: string;
|
|
23
23
|
builtin?: boolean;
|
|
24
|
+
// Where it comes from, for grouping in the "/" menu.
|
|
25
|
+
source?: CommandSource;
|
|
26
|
+
// The plugin that ships it, when source is "plugin".
|
|
27
|
+
plugin?: string;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
export type CommandSource = "project" | "global" | "plugin" | "builtin";
|
|
31
|
+
|
|
26
32
|
export interface ChatModel {
|
|
27
33
|
value: string;
|
|
28
34
|
label: string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The commands and skills last used from the composer, newest first. Kept in
|
|
2
|
+
// the browser: a short list that only matters to this device.
|
|
3
|
+
|
|
4
|
+
const KEY = "agentos:recent-commands";
|
|
5
|
+
const MAX = 5;
|
|
6
|
+
|
|
7
|
+
export function pushRecent(list: string[], name: string): string[] {
|
|
8
|
+
return [name, ...list.filter((n) => n !== name)].slice(0, MAX);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function loadRecent(): string[] {
|
|
12
|
+
try {
|
|
13
|
+
const v: unknown = JSON.parse(localStorage.getItem(KEY) ?? "[]");
|
|
14
|
+
return Array.isArray(v)
|
|
15
|
+
? v.filter((n): n is string => typeof n === "string").slice(0, MAX)
|
|
16
|
+
: [];
|
|
17
|
+
} catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function saveRecent(name: string): string[] {
|
|
23
|
+
const next = pushRecent(loadRecent(), name);
|
|
24
|
+
try {
|
|
25
|
+
localStorage.setItem(KEY, JSON.stringify(next));
|
|
26
|
+
} catch {}
|
|
27
|
+
return next;
|
|
28
|
+
}
|
package/lib/chat/settings.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type ChatCommand,
|
|
17
17
|
type ChatServerMessage,
|
|
18
18
|
} from "./events";
|
|
19
|
+
import { tagSources } from "./skill-source";
|
|
19
20
|
import {
|
|
20
21
|
createSkillWatcher,
|
|
21
22
|
skillFolders,
|
|
@@ -200,6 +201,7 @@ async function loadCapabilities(
|
|
|
200
201
|
if (w.loads.get(key)?.gen !== gen) return;
|
|
201
202
|
registry.caps.set(key, {
|
|
202
203
|
...found,
|
|
204
|
+
commands: tagSources(found.commands, cwdOf(session)),
|
|
203
205
|
terminalOnly: registry.caps.get(key)?.terminalOnly ?? new Set(),
|
|
204
206
|
at: Date.now(),
|
|
205
207
|
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tells the "/" menu where each command or skill comes from: the project's
|
|
3
|
+
* own folders, the user's, a plugin (its name carries the plugin as a
|
|
4
|
+
* "plugin:" prefix), or the agent itself.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { SKILL_DIRS } from "../agents/skill-dirs";
|
|
11
|
+
import type { ChatCommand } from "./events";
|
|
12
|
+
|
|
13
|
+
const claudeDir = () =>
|
|
14
|
+
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
15
|
+
|
|
16
|
+
function entries(dir: string): fs.Dirent[] {
|
|
17
|
+
try {
|
|
18
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const isDir = (e: fs.Dirent, dir: string) =>
|
|
25
|
+
e.isDirectory() ||
|
|
26
|
+
(e.isSymbolicLink() && fs.statSync(path.join(dir, e.name)).isDirectory());
|
|
27
|
+
|
|
28
|
+
// The name in a SKILL.md's frontmatter, when it differs from the folder.
|
|
29
|
+
function declaredName(file: string): string | null {
|
|
30
|
+
try {
|
|
31
|
+
const head = fs.readFileSync(file, "utf8").slice(0, 1024);
|
|
32
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(head)?.[1] ?? "";
|
|
33
|
+
return /^name:\s*["']?([^\s"']+)/m.exec(fm)?.[1] ?? null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Names a folder of skills and commands offers: skill folders, and command
|
|
40
|
+
// files (nested ones as "dir:name").
|
|
41
|
+
export function namesIn(root: string): Set<string> {
|
|
42
|
+
const names = new Set<string>();
|
|
43
|
+
const skills = path.join(root, "skills");
|
|
44
|
+
for (const e of entries(skills)) {
|
|
45
|
+
try {
|
|
46
|
+
if (!isDir(e, skills)) continue;
|
|
47
|
+
} catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
names.add(e.name);
|
|
51
|
+
const declared = declaredName(path.join(skills, e.name, "SKILL.md"));
|
|
52
|
+
if (declared) names.add(declared);
|
|
53
|
+
}
|
|
54
|
+
const commands = path.join(root, "commands");
|
|
55
|
+
const walk = (dir: string, prefix: string) => {
|
|
56
|
+
for (const e of entries(dir)) {
|
|
57
|
+
if (e.isDirectory()) walk(path.join(dir, e.name), `${prefix}${e.name}:`);
|
|
58
|
+
else if (e.name.endsWith(".md"))
|
|
59
|
+
names.add(`${prefix}${e.name.slice(0, -3)}`);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
walk(commands, "");
|
|
63
|
+
return names;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function projectNames(cwd: string): Set<string> {
|
|
67
|
+
const names = new Set<string>();
|
|
68
|
+
const home = os.homedir();
|
|
69
|
+
// The folder and its parents, up to home: a repo's skills reach its subfolders.
|
|
70
|
+
for (let dir = cwd; dir !== home && dir !== path.dirname(dir);) {
|
|
71
|
+
for (const rel of SKILL_DIRS) {
|
|
72
|
+
// SKILL_DIRS are ".claude/skills" style; namesIn wants the parent.
|
|
73
|
+
for (const n of namesIn(path.join(dir, path.dirname(rel)))) names.add(n);
|
|
74
|
+
}
|
|
75
|
+
dir = path.dirname(dir);
|
|
76
|
+
}
|
|
77
|
+
return names;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function tagSources(
|
|
81
|
+
commands: ChatCommand[],
|
|
82
|
+
cwd: string
|
|
83
|
+
): ChatCommand[] {
|
|
84
|
+
const project = projectNames(cwd);
|
|
85
|
+
const user = namesIn(claudeDir());
|
|
86
|
+
return commands.map((c) => {
|
|
87
|
+
if (project.has(c.name)) return { ...c, source: "project" };
|
|
88
|
+
if (user.has(c.name)) return { ...c, source: "global" };
|
|
89
|
+
if (c.builtin) return { ...c, source: "builtin" };
|
|
90
|
+
const colon = c.name.indexOf(":");
|
|
91
|
+
if (colon > 0)
|
|
92
|
+
return { ...c, source: "plugin", plugin: c.name.slice(0, colon) };
|
|
93
|
+
return { ...c, source: "builtin" };
|
|
94
|
+
});
|
|
95
|
+
}
|