Skip to content

Commit 371e5be

Browse files
committed
feat: Create Skill button with template scaffolding
- New "Create" button in sidebar below Marketplace - Modal with name input + target dropdown (agent + type) - Creates SKILL.md in directory for skills, flat .md for commands - Auto-refreshes store and selects the new skill after creation - Slugifies name for filesystem, validates for duplicates Closes #17
1 parent e59abf3 commit 371e5be

5 files changed

Lines changed: 204 additions & 5 deletions

File tree

main.js

Lines changed: 5 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/views/create-skill-modal.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { Modal, Notice, Setting, type App } from "obsidian";
2+
import { existsSync, mkdirSync, writeFileSync } from "fs";
3+
import { join } from "path";
4+
import { TOOL_CONFIGS } from "../tool-configs";
5+
import type { SkillType } from "../types";
6+
7+
interface CreateTarget {
8+
toolId: string;
9+
toolName: string;
10+
baseDir: string;
11+
type: SkillType;
12+
pattern: string;
13+
}
14+
15+
function getCreateTargets(): CreateTarget[] {
16+
const targets: CreateTarget[] = [];
17+
for (const tool of TOOL_CONFIGS) {
18+
if (!tool.isInstalled()) continue;
19+
for (const sp of [...tool.paths, ...tool.agentPaths]) {
20+
if (sp.type === "rule" || sp.type === "memory") continue;
21+
targets.push({
22+
toolId: tool.id,
23+
toolName: tool.name,
24+
baseDir: sp.baseDir,
25+
type: sp.type,
26+
pattern: sp.pattern,
27+
});
28+
}
29+
}
30+
return targets;
31+
}
32+
33+
function buildTargetLabel(t: CreateTarget): string {
34+
return `${t.toolName}${t.type}`;
35+
}
36+
37+
export class CreateSkillModal extends Modal {
38+
private onCreated: (filePath: string) => void;
39+
private name = "";
40+
private selectedTarget: CreateTarget | null = null;
41+
private targets: CreateTarget[];
42+
43+
constructor(app: App, onCreated: (filePath: string) => void) {
44+
super(app);
45+
this.onCreated = onCreated;
46+
this.targets = getCreateTargets();
47+
if (this.targets.length > 0) {
48+
this.selectedTarget = this.targets[0];
49+
}
50+
}
51+
52+
onOpen(): void {
53+
const { contentEl } = this;
54+
contentEl.addClass("as-create-modal");
55+
56+
contentEl.createEl("h3", { text: "Create Skill" });
57+
58+
new Setting(contentEl)
59+
.setName("Name")
60+
.setDesc("Skill name (used for directory and display)")
61+
.addText((text) =>
62+
text
63+
.setPlaceholder("my-awesome-skill")
64+
.onChange((value) => {
65+
this.name = value.trim();
66+
})
67+
);
68+
69+
if (this.targets.length > 0) {
70+
const options: Record<string, string> = {};
71+
for (let i = 0; i < this.targets.length; i++) {
72+
options[String(i)] = buildTargetLabel(this.targets[i]);
73+
}
74+
75+
new Setting(contentEl)
76+
.setName("Target")
77+
.setDesc("Where to create the skill")
78+
.addDropdown((drop) =>
79+
drop
80+
.addOptions(options)
81+
.setValue("0")
82+
.onChange((value) => {
83+
this.selectedTarget = this.targets[parseInt(value)];
84+
})
85+
);
86+
}
87+
88+
const btnContainer = contentEl.createDiv("as-create-buttons");
89+
const cancelBtn = btnContainer.createEl("button", { text: "Cancel" });
90+
cancelBtn.addEventListener("click", () => this.close());
91+
92+
const createBtn = btnContainer.createEl("button", {
93+
text: "Create",
94+
cls: "mod-cta",
95+
});
96+
createBtn.addEventListener("click", () => this.create());
97+
}
98+
99+
private create(): void {
100+
if (!this.name) {
101+
new Notice("Please enter a skill name");
102+
return;
103+
}
104+
if (!this.selectedTarget) {
105+
new Notice("No target available");
106+
return;
107+
}
108+
109+
const slug = this.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
110+
const t = this.selectedTarget;
111+
let filePath: string;
112+
113+
if (t.pattern === "directory-with-skillmd") {
114+
const dir = join(t.baseDir, slug);
115+
if (existsSync(dir)) {
116+
new Notice(`Directory already exists: ${slug}`);
117+
return;
118+
}
119+
mkdirSync(dir, { recursive: true });
120+
filePath = join(dir, "SKILL.md");
121+
writeFileSync(filePath, [
122+
"---",
123+
`name: ${this.name}`,
124+
'description: ""',
125+
"---",
126+
"",
127+
`# ${this.name}`,
128+
"",
129+
"## Instructions",
130+
"",
131+
"",
132+
].join("\n"), "utf-8");
133+
} else {
134+
if (!existsSync(t.baseDir)) {
135+
mkdirSync(t.baseDir, { recursive: true });
136+
}
137+
filePath = join(t.baseDir, `${slug}.md`);
138+
if (existsSync(filePath)) {
139+
new Notice(`File already exists: ${slug}.md`);
140+
return;
141+
}
142+
writeFileSync(filePath, [
143+
"---",
144+
`description: ""`,
145+
"---",
146+
"",
147+
"",
148+
].join("\n"), "utf-8");
149+
}
150+
151+
new Notice(`Created ${this.name}`);
152+
this.close();
153+
this.onCreated(filePath);
154+
}
155+
156+
onClose(): void {
157+
this.contentEl.empty();
158+
}
159+
}

src/views/main-view.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ListPanel } from "./list";
66
import { DetailPanel } from "./detail";
77
import { DashboardPanel } from "./dashboard";
88
import { MarketplacePanel } from "./marketplace-view";
9+
import { CreateSkillModal } from "./create-skill-modal";
910

1011
export const VIEW_TYPE = "agentfiles-view";
1112

@@ -74,7 +75,8 @@ export class AgentfilesView extends ItemView {
7475
this.sidebarEl,
7576
this.store,
7677
() => this.toggleDashboard(),
77-
() => this.toggleMarketplace()
78+
() => this.toggleMarketplace(),
79+
() => this.openCreateModal()
7880
);
7981
this.listPanel = new ListPanel(this.listEl, this.store, (item: SkillItem) =>
8082
this.onSelectItem(item)
@@ -158,6 +160,16 @@ export class AgentfilesView extends ItemView {
158160
}
159161
}
160162

163+
private openCreateModal(): void {
164+
new CreateSkillModal(this.app, (filePath: string) => {
165+
this.store.refresh(this.settings);
166+
setTimeout(() => {
167+
const created = this.store.allItems.find((i) => i.filePath === filePath || i.realPath === filePath);
168+
if (created) this.onSelectItem(created);
169+
}, 100);
170+
}).open();
171+
}
172+
161173
private onSelectItem(item: SkillItem): void {
162174
if (this.isDashboard) this.toggleDashboard();
163175
if (this.isMarketplace) this.toggleMarketplace();

src/views/sidebar.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@ export class SidebarPanel {
1010
private store: SkillStore;
1111
private onToggleDashboard: () => void;
1212
private onToggleMarketplace: () => void;
13+
private onCreateSkill: () => void;
1314
private dashboardActive = false;
1415
private marketplaceActive = false;
1516

16-
constructor(containerEl: HTMLElement, store: SkillStore, onToggleDashboard: () => void, onToggleMarketplace: () => void) {
17+
constructor(containerEl: HTMLElement, store: SkillStore, onToggleDashboard: () => void, onToggleMarketplace: () => void, onCreateSkill: () => void) {
1718
this.containerEl = containerEl;
1819
this.store = store;
1920
this.onToggleDashboard = onToggleDashboard;
2021
this.onToggleMarketplace = onToggleMarketplace;
22+
this.onCreateSkill = onCreateSkill;
2123
}
2224

2325
setDashboardActive(active: boolean): void {
@@ -229,6 +231,12 @@ export class SidebarPanel {
229231
if (this.dashboardActive) this.onToggleDashboard();
230232
if (!this.marketplaceActive) this.onToggleMarketplace();
231233
});
234+
235+
const createRow = section.createDiv("as-sidebar-item as-sidebar-create");
236+
const createIcon = createRow.createSpan("as-sidebar-icon");
237+
setIcon(createIcon, "plus");
238+
createRow.createSpan({ cls: "as-sidebar-label", text: "Create" });
239+
createRow.addEventListener("click", () => this.onCreateSkill());
232240
}
233241

234242
private renderSkillkitCta(): void {

styles.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1920,3 +1920,21 @@
19201920
.as-filter-clear:hover {
19211921
text-decoration: underline;
19221922
}
1923+
1924+
/* Create skill */
1925+
.as-sidebar-create {
1926+
border-top: 1px solid var(--background-modifier-border);
1927+
margin-top: 4px;
1928+
padding-top: 8px !important;
1929+
}
1930+
1931+
.as-create-modal h3 {
1932+
margin: 0 0 12px;
1933+
}
1934+
1935+
.as-create-buttons {
1936+
display: flex;
1937+
justify-content: flex-end;
1938+
gap: 8px;
1939+
margin-top: 16px;
1940+
}

0 commit comments

Comments
 (0)