ide-agents 0.1.1 → 0.2.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ide-agents
2
2
 
3
- Local admin for **IDE agents and skills** (Cursor and similar) from any git repository.
3
+ Local admin for **IDE agents and skills** from any git repository — **Cursor**, **Claude Code**, and **Codex** (enable in Settings).
4
4
 
5
5
  Install skills and subagents into your IDE via symlinks — no copy-paste, no manual path juggling.
6
6
 
@@ -11,8 +11,8 @@ Install skills and subagents into your IDE via symlinks — no copy-paste, no ma
11
11
 
12
12
  - Clones git repositories into `~/.ide-agents/repos/`
13
13
  - Scans `skills/*/SKILL.md` and optional `agents/*.md`
14
- - Creates symlinks in `~/.cursor/` (global) or `<project>/.cursor/` (per-project)
15
- - Provides a browser UI for repos, skills, and agents
14
+ - Creates symlinks in each enabled tool’s config directory and per-project folders
15
+ - Provides a browser UI: Settings, Repositories, Skills, Agents
16
16
 
17
17
  **Not affiliated with Cursor.**
18
18
 
@@ -59,10 +59,16 @@ ide-agents --port 3922 # custom port
59
59
  ide-agents --no-open # do not open browser
60
60
  ```
61
61
 
62
+ ## Settings
63
+
64
+ Open **Settings** (`/settings`) and enable the tools you use (Codex, Claude, Cursor). Set each **config path** (defaults: `~/.codex`, `~/.claude`, `~/.cursor`).
65
+
66
+ On first run, a tool is enabled only if its default folder already exists in your home directory. You can change paths and toggles anytime; installs apply to all enabled tools.
67
+
62
68
  ## Add a repository
63
69
 
64
- 1. Open **Settings** in the UI
65
- 2. Enter a git URL and branch (default `main`)
70
+ 1. Open **Repositories** in the UI
71
+ 2. Pick a suggested catalog or enter a git URL and branch (default `main`)
66
72
  3. Click **Add / Clone**
67
73
 
68
74
  Your repo should contain:
@@ -89,10 +95,13 @@ Private repos: configure SSH or `gh` auth yourself — ide-agents does not store
89
95
  2. Select a repository
90
96
  3. Click **Global** (🌐) or **Project** (📁) on a card — symlinks apply immediately
91
97
 
92
- | Kind | Global | Project |
93
- |-------|--------------------------------|--------------------------------------|
94
- | Skill | `~/.cursor/skills/<name>` | `<project>/.cursor/skills/<name>` |
95
- | Agent | `~/.cursor/agents/<name>.md` | `<project>/.cursor/agents/<name>.md` |
98
+ | Tool | Global (default config path) | Project subfolder |
99
+ |--------|------------------------------|-------------------|
100
+ | Cursor | `~/.cursor/` | `.cursor` |
101
+ | Claude | `~/.claude/` | `.claude` |
102
+ | Codex | `~/.codex/` | `.agents` |
103
+
104
+ Global paths use your configured **config path** per tool. Project path is the directory where you started `ide-agents`.
96
105
 
97
106
  Click the active icon again to remove the symlink (only if target is already a symlink).
98
107
 
@@ -124,11 +133,13 @@ Then add `file://$(pwd)` in the UI.
124
133
 
125
134
  ```
126
135
  ~/.ide-agents/
127
- ├── config.json
136
+ ├── config.json # repos, installations, ides (per-tool enable + paths)
128
137
  └── repos/
129
138
  └── <slug>/ # git clone
130
139
  ```
131
140
 
141
+ Docs: [Settings & IDEs](https://ide-agents.vercel.app/docs/settings) on the project site.
142
+
132
143
  ## License
133
144
 
134
145
  MIT
@@ -0,0 +1,9 @@
1
+ import type { IdeId } from "../types.js";
2
+ import type { Adapter } from "./types.js";
3
+ interface IdeLayout {
4
+ id: IdeId;
5
+ /** Subfolder under a project root for project-scoped installs. */
6
+ projectDir: string;
7
+ }
8
+ export declare function createAdapter(layout: IdeLayout, configPath: string): Adapter;
9
+ export {};
@@ -0,0 +1,33 @@
1
+ import path from "node:path";
2
+ import { expandUserPath, resolveProjectPath } from "../paths.js";
3
+ function skillsDir(base) {
4
+ return path.join(base, "skills");
5
+ }
6
+ function agentsDir(base) {
7
+ return path.join(base, "agents");
8
+ }
9
+ function targetPath(base, kind, targetName) {
10
+ if (kind === "skill") {
11
+ return path.join(skillsDir(base), targetName);
12
+ }
13
+ return path.join(agentsDir(base), `${targetName}.md`);
14
+ }
15
+ export function createAdapter(layout, configPath) {
16
+ const globalBase = expandUserPath(configPath);
17
+ return {
18
+ id: layout.id,
19
+ getGlobalTargetPath(installation) {
20
+ return targetPath(globalBase, installation.kind, installation.targetName);
21
+ },
22
+ getProjectTargetPath(installation) {
23
+ if (!installation.projectPath) {
24
+ throw new Error("projectPath is required for project target");
25
+ }
26
+ const projectBase = path.join(resolveProjectPath(installation.projectPath), layout.projectDir);
27
+ return targetPath(projectBase, installation.kind, installation.targetName);
28
+ },
29
+ getSourcePath(repoRoot, installation) {
30
+ return path.join(repoRoot, installation.sourcePath);
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,6 @@
1
+ import type { IdeAgentsConfig, IdeId } from "../types.js";
2
+ import type { Adapter } from "./types.js";
3
+ export declare function getAdapter(ideId: IdeId, configPath: string): Adapter;
4
+ export declare function getEnabledAdapters(config: IdeAgentsConfig): Adapter[];
5
+ export declare function isSymlinkType(kind: "skill" | "agent"): "dir" | "file";
6
+ export type { Adapter } from "./types.js";
@@ -0,0 +1,16 @@
1
+ import { getEnabledIdeIds } from "../ides.js";
2
+ import { createAdapter } from "./create.js";
3
+ const LAYOUTS = {
4
+ cursor: { id: "cursor", projectDir: ".cursor" },
5
+ claude: { id: "claude", projectDir: ".claude" },
6
+ codex: { id: "codex", projectDir: ".agents" },
7
+ };
8
+ export function getAdapter(ideId, configPath) {
9
+ return createAdapter(LAYOUTS[ideId], configPath);
10
+ }
11
+ export function getEnabledAdapters(config) {
12
+ return getEnabledIdeIds(config).map((id) => getAdapter(id, config.ides[id].configPath));
13
+ }
14
+ export function isSymlinkType(kind) {
15
+ return kind === "skill" ? "dir" : "file";
16
+ }
@@ -0,0 +1,7 @@
1
+ import type { IdeId, Installation } from "../types.js";
2
+ export interface Adapter {
3
+ id: IdeId;
4
+ getGlobalTargetPath(installation: Pick<Installation, "kind" | "targetName">): string;
5
+ getProjectTargetPath(installation: Pick<Installation, "kind" | "targetName" | "projectPath">): string;
6
+ getSourcePath(repoRoot: string, installation: Pick<Installation, "sourcePath">): string;
7
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/apply.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { lstat, mkdir, readlink, symlink, unlink } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { getAdapter } from "./adapters/cursor.js";
4
- import { getRepoPath } from "./paths.js";
3
+ import { getEnabledAdapters } from "./adapters/index.js";
4
+ import { addManagedGitignoreEntry, removeManagedGitignoreEntry, toGitignorePath, } from "./gitignore.js";
5
+ import { getRepoPath, resolveProjectPath } from "./paths.js";
5
6
  async function pathExists(filePath) {
6
7
  try {
7
8
  await lstat(filePath);
@@ -52,11 +53,29 @@ async function createSymlink(targetPath, sourcePath, type) {
52
53
  await symlink(resolvedSource, targetPath, type);
53
54
  return { path: targetPath, action: "created" };
54
55
  }
55
- async function applyScope(installation, sourcePath, type, enabled, targetPath) {
56
+ async function syncProjectGitignore(projectRoot, targetPath, mode) {
57
+ if (!projectRoot) {
58
+ return;
59
+ }
60
+ const entry = toGitignorePath(projectRoot, targetPath);
61
+ if (!entry) {
62
+ return;
63
+ }
64
+ if (mode === "add") {
65
+ await addManagedGitignoreEntry(projectRoot, entry);
66
+ return;
67
+ }
68
+ await removeManagedGitignoreEntry(projectRoot, entry);
69
+ }
70
+ async function applyScope(sourcePath, type, enabled, targetPath, projectRoot) {
56
71
  const results = [];
57
72
  if (enabled) {
58
73
  try {
59
- results.push(await createSymlink(targetPath, sourcePath, type));
74
+ const result = await createSymlink(targetPath, sourcePath, type);
75
+ results.push(result);
76
+ if (!result.error) {
77
+ await syncProjectGitignore(projectRoot, targetPath, "add");
78
+ }
60
79
  }
61
80
  catch (err) {
62
81
  results.push({
@@ -70,12 +89,16 @@ async function applyScope(installation, sourcePath, type, enabled, targetPath) {
70
89
  const removed = await removeSymlinkIfExists(targetPath);
71
90
  if (removed.action === "removed") {
72
91
  results.push(removed);
92
+ await syncProjectGitignore(projectRoot, targetPath, "remove");
73
93
  }
74
94
  return results;
75
95
  }
76
96
  export async function applyInstallations(config) {
77
- const adapter = getAdapter(config.adapter);
97
+ const adapters = getEnabledAdapters(config);
78
98
  const results = [];
99
+ if (adapters.length === 0) {
100
+ return { results };
101
+ }
79
102
  for (const installation of config.installations) {
80
103
  const repo = config.repos.find((r) => r.id === installation.repoId);
81
104
  if (!repo) {
@@ -87,13 +110,17 @@ export async function applyInstallations(config) {
87
110
  continue;
88
111
  }
89
112
  const repoRoot = getRepoPath(repo.slug);
90
- const sourcePath = adapter.getSourcePath(repoRoot, installation);
91
113
  const type = installation.kind === "skill" ? "dir" : "file";
92
- const globalTarget = adapter.getGlobalTargetPath(installation);
93
- results.push(...(await applyScope(installation, sourcePath, type, installation.global, globalTarget)));
94
- if (installation.projectPath) {
95
- const projectTarget = adapter.getProjectTargetPath(installation);
96
- results.push(...(await applyScope(installation, sourcePath, type, installation.project, projectTarget)));
114
+ for (const adapter of adapters) {
115
+ const sourcePath = adapter.getSourcePath(repoRoot, installation);
116
+ const globalTarget = adapter.getGlobalTargetPath(installation);
117
+ results.push(...(await applyScope(sourcePath, type, installation.global, globalTarget, null)));
118
+ // projectPath is kept in config while project is off so removal can run.
119
+ if (installation.projectPath) {
120
+ const projectRoot = resolveProjectPath(installation.projectPath);
121
+ const projectTarget = adapter.getProjectTargetPath(installation);
122
+ results.push(...(await applyScope(sourcePath, type, installation.project, projectTarget, projectRoot)));
123
+ }
97
124
  }
98
125
  }
99
126
  return { results };
package/dist/config.js CHANGED
@@ -2,14 +2,19 @@ import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { getConfigPath, getIdeAgentsHome } from "./paths.js";
5
- const DEFAULT_CONFIG = {
6
- version: 1,
7
- adapter: "cursor",
8
- server: { port: 3921 },
9
- repos: [],
10
- installations: [],
11
- recentProjects: [],
12
- };
5
+ import { defaultAdapterFromIdes, getDefaultIdes, migrateIdesConfig, } from "./ides.js";
6
+ function buildDefaultConfig() {
7
+ const ides = getDefaultIdes();
8
+ return {
9
+ version: 1,
10
+ adapter: defaultAdapterFromIdes(ides),
11
+ ides,
12
+ server: { port: 3921 },
13
+ repos: [],
14
+ installations: [],
15
+ recentProjects: [],
16
+ };
17
+ }
13
18
  const LEGACY_HOME = path.join(homedir(), ".agentdesk");
14
19
  async function fileExists(filePath) {
15
20
  try {
@@ -38,6 +43,26 @@ function migrateInstallation(raw) {
38
43
  projectPath: item.projectPath ?? null,
39
44
  };
40
45
  }
46
+ function isLegacySampleRepo(repo) {
47
+ if (repo.id === "sample") {
48
+ return true;
49
+ }
50
+ const normalized = repo.url.toLowerCase();
51
+ return (normalized.includes("fixtures/sample-repo") ||
52
+ normalized.includes("fixtures%2fsample-repo"));
53
+ }
54
+ function stripLegacySampleRepo(config) {
55
+ const repos = config.repos.filter((r) => !isLegacySampleRepo(r));
56
+ if (repos.length === config.repos.length) {
57
+ return config;
58
+ }
59
+ const removedIds = new Set(config.repos.filter(isLegacySampleRepo).map((r) => r.id));
60
+ return {
61
+ ...config,
62
+ repos,
63
+ installations: config.installations.filter((i) => !removedIds.has(i.repoId)),
64
+ };
65
+ }
41
66
  async function migrateLegacyHome() {
42
67
  const home = getIdeAgentsHome();
43
68
  if (home === LEGACY_HOME) {
@@ -57,20 +82,30 @@ export async function ensureIdeAgentsHome() {
57
82
  export async function readConfig() {
58
83
  await ensureIdeAgentsHome();
59
84
  const configPath = getConfigPath();
85
+ const defaults = buildDefaultConfig();
60
86
  if (!(await fileExists(configPath))) {
61
- await writeConfig(DEFAULT_CONFIG);
62
- return structuredClone(DEFAULT_CONFIG);
87
+ await writeConfig(defaults);
88
+ return structuredClone(defaults);
63
89
  }
64
90
  const raw = await readFile(configPath, "utf8");
65
91
  const parsed = JSON.parse(raw);
66
- return {
67
- ...DEFAULT_CONFIG,
92
+ const ides = migrateIdesConfig(parsed);
93
+ let config = {
94
+ ...defaults,
68
95
  ...parsed,
69
- server: { ...DEFAULT_CONFIG.server, ...parsed.server },
96
+ adapter: parsed.adapter ?? defaultAdapterFromIdes(ides),
97
+ ides,
98
+ server: { ...defaults.server, ...parsed.server },
70
99
  repos: parsed.repos ?? [],
71
100
  installations: (parsed.installations ?? []).map(migrateInstallation),
72
101
  recentProjects: parsed.recentProjects ?? [],
73
102
  };
103
+ const withoutSample = stripLegacySampleRepo(config);
104
+ if (withoutSample.repos.length !== config.repos.length) {
105
+ await writeConfig(withoutSample);
106
+ config = withoutSample;
107
+ }
108
+ return config;
74
109
  }
75
110
  export async function writeConfig(config) {
76
111
  await ensureIdeAgentsHome();
package/dist/git.js CHANGED
@@ -20,10 +20,26 @@ async function runGit(cwd, args) {
20
20
  });
21
21
  return { stdout: result.stdout.trim(), stderr: result.stderr.trim() };
22
22
  }
23
+ async function syncExistingRepo(target, ref) {
24
+ try {
25
+ await runGit(target, ["fetch", "--all", "--prune"]);
26
+ try {
27
+ await runGit(target, ["checkout", ref]);
28
+ }
29
+ catch {
30
+ // ref may be a tag or detached state — pull current branch below
31
+ }
32
+ await runGit(target, ["pull", "--ff-only"]);
33
+ }
34
+ catch {
35
+ // Best effort: re-attaching a previously removed repo still succeeds
36
+ }
37
+ }
23
38
  export async function cloneRepo(url, slug, ref) {
24
39
  const target = getRepoPath(slug);
25
40
  if (await isGitRepo(target)) {
26
- throw new Error(`Repository already cloned at ${target}`);
41
+ await syncExistingRepo(target, ref);
42
+ return target;
27
43
  }
28
44
  await execFileAsync("git", ["clone", "--branch", ref, url, target], {
29
45
  maxBuffer: 10 * 1024 * 1024,
@@ -0,0 +1,5 @@
1
+ export declare const IDE_AGENTS_GITIGNORE_HEADER = "# ide-agents (managed \u2014 do not edit manually)";
2
+ export declare function toGitignorePath(projectRoot: string, targetPath: string): string | null;
3
+ export declare function isGitRepository(projectRoot: string): Promise<boolean>;
4
+ export declare function addManagedGitignoreEntry(projectRoot: string, entry: string): Promise<void>;
5
+ export declare function removeManagedGitignoreEntry(projectRoot: string, entry: string): Promise<void>;
@@ -0,0 +1,105 @@
1
+ import { access, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ export const IDE_AGENTS_GITIGNORE_HEADER = "# ide-agents (managed — do not edit manually)";
4
+ export function toGitignorePath(projectRoot, targetPath) {
5
+ const relative = path.relative(path.resolve(projectRoot), path.resolve(targetPath));
6
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
7
+ return null;
8
+ }
9
+ return relative.split(path.sep).join("/");
10
+ }
11
+ export async function isGitRepository(projectRoot) {
12
+ try {
13
+ await access(path.join(projectRoot, ".git"));
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ function parseGitignore(content) {
21
+ const lines = content.split("\n");
22
+ const headerIndex = lines.findIndex((line) => line.trim() === IDE_AGENTS_GITIGNORE_HEADER);
23
+ if (headerIndex === -1) {
24
+ return { before: content.replace(/\n?$/, ""), entries: [], after: "" };
25
+ }
26
+ const before = lines.slice(0, headerIndex).join("\n").replace(/\n?$/, "");
27
+ const entries = [];
28
+ let index = headerIndex + 1;
29
+ while (index < lines.length) {
30
+ const line = lines[index];
31
+ const trimmed = line.trim();
32
+ if (trimmed === "") {
33
+ index += 1;
34
+ break;
35
+ }
36
+ if (trimmed.startsWith("#")) {
37
+ break;
38
+ }
39
+ entries.push(line);
40
+ index += 1;
41
+ }
42
+ const after = lines.slice(index).join("\n").replace(/^\n?/, "");
43
+ return { before, entries, after };
44
+ }
45
+ function formatGitignore(parsed) {
46
+ const parts = [];
47
+ if (parsed.before.length > 0) {
48
+ parts.push(parsed.before);
49
+ }
50
+ if (parsed.entries.length > 0) {
51
+ parts.push(IDE_AGENTS_GITIGNORE_HEADER);
52
+ parts.push(...parsed.entries);
53
+ parts.push("");
54
+ }
55
+ if (parsed.after.length > 0) {
56
+ parts.push(parsed.after.replace(/\n?$/, ""));
57
+ }
58
+ if (parts.length === 0) {
59
+ return "";
60
+ }
61
+ return `${parts.join("\n")}\n`;
62
+ }
63
+ async function readGitignore(gitignorePath) {
64
+ try {
65
+ return await readFile(gitignorePath, "utf8");
66
+ }
67
+ catch (err) {
68
+ if (err.code === "ENOENT") {
69
+ return "";
70
+ }
71
+ throw err;
72
+ }
73
+ }
74
+ async function writeGitignoreIfChanged(gitignorePath, nextContent) {
75
+ const current = await readGitignore(gitignorePath);
76
+ if (current === nextContent) {
77
+ return;
78
+ }
79
+ await writeFile(gitignorePath, nextContent, "utf8");
80
+ }
81
+ export async function addManagedGitignoreEntry(projectRoot, entry) {
82
+ if (!(await isGitRepository(projectRoot))) {
83
+ return;
84
+ }
85
+ const gitignorePath = path.join(projectRoot, ".gitignore");
86
+ const parsed = parseGitignore(await readGitignore(gitignorePath));
87
+ if (parsed.entries.includes(entry)) {
88
+ return;
89
+ }
90
+ parsed.entries.push(entry);
91
+ parsed.entries.sort((a, b) => a.localeCompare(b));
92
+ await writeGitignoreIfChanged(gitignorePath, formatGitignore(parsed));
93
+ }
94
+ export async function removeManagedGitignoreEntry(projectRoot, entry) {
95
+ if (!(await isGitRepository(projectRoot))) {
96
+ return;
97
+ }
98
+ const gitignorePath = path.join(projectRoot, ".gitignore");
99
+ const parsed = parseGitignore(await readGitignore(gitignorePath));
100
+ if (!parsed.entries.includes(entry)) {
101
+ return;
102
+ }
103
+ parsed.entries = parsed.entries.filter((line) => line !== entry);
104
+ await writeGitignoreIfChanged(gitignorePath, formatGitignore(parsed));
105
+ }
package/dist/ides.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { AdapterId, IdeAgentsConfig, IdeId, IdesConfig } from "./types.js";
2
+ export declare function getDefaultIdes(): IdesConfig;
3
+ export declare function defaultAdapterFromIdes(ides: IdesConfig): AdapterId;
4
+ export declare function migrateIdesConfig(parsed: Partial<IdeAgentsConfig>): IdesConfig;
5
+ export declare function getEnabledIdeIds(config: IdeAgentsConfig): IdeId[];
package/dist/ides.js ADDED
@@ -0,0 +1,52 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ function homeDirExists(dirPath) {
5
+ try {
6
+ return existsSync(dirPath) && statSync(dirPath).isDirectory();
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ export function getDefaultIdes() {
13
+ const home = homedir();
14
+ const cursorPath = path.join(home, ".cursor");
15
+ const claudePath = path.join(home, ".claude");
16
+ const codexPath = path.join(home, ".codex");
17
+ return {
18
+ cursor: { enabled: homeDirExists(cursorPath), configPath: cursorPath },
19
+ claude: { enabled: homeDirExists(claudePath), configPath: claudePath },
20
+ codex: { enabled: homeDirExists(codexPath), configPath: codexPath },
21
+ };
22
+ }
23
+ export function defaultAdapterFromIdes(ides) {
24
+ if (ides.cursor.enabled)
25
+ return "cursor";
26
+ if (ides.claude.enabled)
27
+ return "claude";
28
+ if (ides.codex.enabled)
29
+ return "codex";
30
+ return "cursor";
31
+ }
32
+ export function migrateIdesConfig(parsed) {
33
+ const defaults = getDefaultIdes();
34
+ if (!parsed.ides) {
35
+ return defaults;
36
+ }
37
+ return {
38
+ cursor: { ...defaults.cursor, ...parsed.ides.cursor },
39
+ claude: { ...defaults.claude, ...parsed.ides.claude },
40
+ codex: { ...defaults.codex, ...parsed.ides.codex },
41
+ };
42
+ }
43
+ export function getEnabledIdeIds(config) {
44
+ const ids = [];
45
+ if (config.ides.cursor.enabled)
46
+ ids.push("cursor");
47
+ if (config.ides.claude.enabled)
48
+ ids.push("claude");
49
+ if (config.ides.codex.enabled)
50
+ ids.push("codex");
51
+ return ids;
52
+ }
package/dist/paths.d.ts CHANGED
@@ -6,3 +6,4 @@ export declare function getRepoPath(slug: string): string;
6
6
  export declare function slugFromUrl(url: string): string;
7
7
  export declare function getWebDistDir(): string;
8
8
  export declare function resolveProjectPath(projectPath: string): string;
9
+ export declare function expandUserPath(inputPath: string): string;
package/dist/paths.js CHANGED
@@ -55,3 +55,13 @@ export function getWebDistDir() {
55
55
  export function resolveProjectPath(projectPath) {
56
56
  return path.resolve(projectPath);
57
57
  }
58
+ export function expandUserPath(inputPath) {
59
+ const trimmed = inputPath.trim();
60
+ if (trimmed === "~") {
61
+ return homedir();
62
+ }
63
+ if (trimmed.startsWith("~/")) {
64
+ return path.join(homedir(), trimmed.slice(2));
65
+ }
66
+ return path.resolve(trimmed);
67
+ }
package/dist/scan.js CHANGED
@@ -15,7 +15,7 @@ function parseAllowedScope(data) {
15
15
  if (scope === "global" || scope === "project" || scope === "any") {
16
16
  return scope;
17
17
  }
18
- return null;
18
+ return "any";
19
19
  }
20
20
  function parseAgentDescription(content, fallbackName) {
21
21
  const parsed = matter(content);
@@ -37,45 +37,74 @@ function parseAgentDescription(content, fallbackName) {
37
37
  }
38
38
  return fallbackName;
39
39
  }
40
- async function scanSkills(repoPath) {
41
- const skillsDir = path.join(repoPath, "skills");
42
- if (!(await pathExists(skillsDir))) {
40
+ async function parseSkillDirectory(skillDir, dirName) {
41
+ const skillMdPath = path.join(skillDir, "SKILL.md");
42
+ const hasSkillMd = await pathExists(skillMdPath);
43
+ let name = dirName;
44
+ let description = "";
45
+ let allowedScope = "any";
46
+ if (hasSkillMd) {
47
+ const raw = await readFile(skillMdPath, "utf8");
48
+ const parsed = matter(raw);
49
+ if (typeof parsed.data.name === "string" && parsed.data.name.trim()) {
50
+ name = parsed.data.name.trim();
51
+ }
52
+ if (typeof parsed.data.description === "string") {
53
+ description = parsed.data.description.trim();
54
+ }
55
+ allowedScope = parseAllowedScope(parsed.data);
56
+ }
57
+ return { id: dirName, name, description, hasSkillMd, allowedScope };
58
+ }
59
+ function toSkillArtifact(sourcePath, parsed) {
60
+ return {
61
+ id: parsed.id,
62
+ kind: "skill",
63
+ sourcePath,
64
+ name: parsed.name,
65
+ description: parsed.description,
66
+ hasSkillMd: parsed.hasSkillMd,
67
+ allowedScope: parsed.allowedScope,
68
+ };
69
+ }
70
+ async function scanSkillsInDirectory(parentDir, sourcePathPrefix) {
71
+ if (!(await pathExists(parentDir))) {
43
72
  return [];
44
73
  }
45
- const entries = await readdir(skillsDir, { withFileTypes: true });
74
+ const entries = await readdir(parentDir, { withFileTypes: true });
46
75
  const artifacts = [];
47
76
  for (const entry of entries) {
48
77
  if (!entry.isDirectory())
49
78
  continue;
50
- const skillDir = path.join(skillsDir, entry.name);
79
+ if (entry.name.startsWith("."))
80
+ continue;
81
+ const skillDir = path.join(parentDir, entry.name);
51
82
  const skillMdPath = path.join(skillDir, "SKILL.md");
52
- const hasSkillMd = await pathExists(skillMdPath);
53
- let name = entry.name;
54
- let description = "";
55
- let allowedScope = null;
56
- if (hasSkillMd) {
57
- const raw = await readFile(skillMdPath, "utf8");
58
- const parsed = matter(raw);
59
- if (typeof parsed.data.name === "string" && parsed.data.name.trim()) {
60
- name = parsed.data.name.trim();
61
- }
62
- if (typeof parsed.data.description === "string") {
63
- description = parsed.data.description.trim();
64
- }
65
- allowedScope = parseAllowedScope(parsed.data);
83
+ if (!(await pathExists(skillMdPath))) {
84
+ continue;
66
85
  }
67
- artifacts.push({
68
- id: entry.name,
69
- kind: "skill",
70
- sourcePath: path.join("skills", entry.name),
71
- name,
72
- description,
73
- hasSkillMd,
74
- allowedScope,
75
- });
86
+ const parsed = await parseSkillDirectory(skillDir, entry.name);
87
+ const relativeSource = sourcePathPrefix
88
+ ? path.join(sourcePathPrefix, entry.name)
89
+ : entry.name;
90
+ artifacts.push(toSkillArtifact(relativeSource, parsed));
76
91
  }
77
92
  return artifacts.sort((a, b) => a.id.localeCompare(b.id));
78
93
  }
94
+ async function scanSkillsNested(repoPath) {
95
+ return scanSkillsInDirectory(path.join(repoPath, "skills"), "skills");
96
+ }
97
+ /** Repos like bluriesophos/cursorskills: `<repo>/<skill-name>/SKILL.md` at root. */
98
+ async function scanSkillsFlat(repoPath) {
99
+ return scanSkillsInDirectory(repoPath, "");
100
+ }
101
+ async function scanSkills(repoPath) {
102
+ const nested = await scanSkillsNested(repoPath);
103
+ if (nested.length > 0) {
104
+ return nested;
105
+ }
106
+ return scanSkillsFlat(repoPath);
107
+ }
79
108
  async function scanAgents(repoPath) {
80
109
  const agentsDir = path.join(repoPath, "agents");
81
110
  if (!(await pathExists(agentsDir))) {