Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/skills/packmind-create-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,10 @@ After testing the skill, users may request improvements. Often this happens righ
Run the following command with the actual skill path:

```bash
packmind-cli skills add <path/to/skill-folder>
packmind-cli skills add <path/to/skill-folder-or-parent> [additional-skill-folders-or-parents...]
```

This registers the skill with Packmind, making it available for deployment to target repositories and AI coding agents.
This registers one or more skills with Packmind, making them available for deployment to target repositories and AI coding agents. Each path can point to a skill folder directly or to a parent folder that contains multiple skill folders.

### Step 8: Offer to Add to Package

Expand Down
4 changes: 2 additions & 2 deletions .cursor/skills/packmind-create-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,10 @@ After testing the skill, users may request improvements. Often this happens righ
Run the following command with the actual skill path:

```bash
packmind-cli skills add <path/to/skill-folder>
packmind-cli skills add <path/to/skill-folder-or-parent> [additional-skill-folders-or-parents...]
```

This registers the skill with Packmind, making it available for deployment to target repositories and AI coding agents.
This registers one or more skills with Packmind, making them available for deployment to target repositories and AI coding agents. Each path can point to a skill folder directly or to a parent folder that contains multiple skill folders.

### Step 8: Offer to Add to Package

Expand Down
4 changes: 2 additions & 2 deletions .github/skills/create-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ Once the skill is validated and ready for distribution, use packmind-cli to add
**Before running the command**, verify that packmind-cli is available (see Prerequisites section). If not installed, install it first.

```bash
packmind-cli skills add <path/to/skill-folder>
packmind-cli skills add <path/to/skill-folder-or-parent> [additional-skill-folders-or-parents...]
```

This command registers the skill with Packmind, making it available for deployment to target repositories and AI coding agents.
This command registers one or more skills with Packmind. Each path can point to a skill folder directly or to a parent folder that contains multiple skill folders.
4 changes: 2 additions & 2 deletions .github/skills/packmind-create-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,10 @@ After testing the skill, users may request improvements. Often this happens righ
Run the following command with the actual skill path:

```bash
packmind-cli skills add <path/to/skill-folder>
packmind-cli skills add <path/to/skill-folder-or-parent> [additional-skill-folders-or-parents...]
```

This registers the skill with Packmind, making it available for deployment to target repositories and AI coding agents.
This registers one or more skills with Packmind, making them available for deployment to target repositories and AI coding agents. Each path can point to a skill folder directly or to a parent folder that contains multiple skill folders.

### Step 8: Offer to Add to Package

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ export default {
moduleFileExtensions: standardModuleFileExtensions,
coverageDirectory: '../../coverage/apps/cli',
transformIgnorePatterns: [
'/node_modules/(?!(chalk|#ansi-styles|#supports-color)/)',
'/node_modules/(?!(chalk|#ansi-styles|#supports-color|strip-ansi|ansi-regex)/)',
],
};
223 changes: 223 additions & 0 deletions apps/cli/src/application/utils/resolveSkillInputPaths.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
resolveSkillDirectoryRoot,
resolveSkillInputPaths,
} from './resolveSkillInputPaths';

function createPermissionDeniedError(
directoryPath: string,
): NodeJS.ErrnoException {
const error = new Error(
`EACCES: permission denied, scandir '${directoryPath}'`,
) as NodeJS.ErrnoException;
error.code = 'EACCES';
error.path = directoryPath;
error.syscall = 'scandir';
return error;
}

describe('resolveSkillInputPaths', () => {
let tempDir: string;

beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-inputs-test-'));
});

afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});

describe('when the input path is already a skill directory', () => {
it('returns that directory', async () => {
const skillDirectoryPath = path.join(tempDir, 'alpha');
await fs.mkdir(skillDirectoryPath, { recursive: true });
await fs.writeFile(path.join(skillDirectoryPath, 'SKILL.md'), 'content');

const resolvedPaths = await resolveSkillInputPaths(['alpha'], tempDir);

expect(resolvedPaths).toEqual([skillDirectoryPath]);
});
});

describe('when the input path is a parent directory containing multiple skills', () => {
it('returns each discovered skill directory', async () => {
const parentDirectoryPath = path.join(tempDir, 'skills');
const alphaSkillDirectoryPath = path.join(parentDirectoryPath, 'alpha');
const betaSkillDirectoryPath = path.join(
parentDirectoryPath,
'nested',
'beta',
);

await fs.mkdir(alphaSkillDirectoryPath, { recursive: true });
await fs.mkdir(betaSkillDirectoryPath, { recursive: true });
await fs.writeFile(
path.join(alphaSkillDirectoryPath, 'SKILL.md'),
'alpha content',
);
await fs.writeFile(
path.join(betaSkillDirectoryPath, 'SKILL.md'),
'beta content',
);

const resolvedPaths = await resolveSkillInputPaths(['skills'], tempDir);

expect(resolvedPaths).toEqual([
alphaSkillDirectoryPath,
betaSkillDirectoryPath,
]);
});
});

describe('when the input path is a file inside a skill directory', () => {
it('returns the parent skill directory', async () => {
const skillDirectoryPath = path.join(tempDir, 'alpha');
await fs.mkdir(skillDirectoryPath, { recursive: true });
await fs.writeFile(path.join(skillDirectoryPath, 'SKILL.md'), 'content');
await fs.writeFile(path.join(skillDirectoryPath, 'README.md'), 'readme');

const resolvedPaths = await resolveSkillInputPaths(
[path.join('alpha', 'README.md')],
tempDir,
);

expect(resolvedPaths).toEqual([skillDirectoryPath]);
});
});

describe('when the same skill is reachable through multiple inputs', () => {
it('deduplicates the resolved skill directories', async () => {
const skillDirectoryPath = path.join(tempDir, 'alpha');
await fs.mkdir(skillDirectoryPath, { recursive: true });
await fs.writeFile(path.join(skillDirectoryPath, 'SKILL.md'), 'content');

const resolvedPaths = await resolveSkillInputPaths(
['alpha', path.join('alpha', 'SKILL.md')],
tempDir,
);

expect(resolvedPaths).toEqual([skillDirectoryPath]);
});
});

describe('when the parent directory contains ignored directories', () => {
it('skips ignored dependency and cache directories when scanning for skills', async () => {
const parentDirectoryPath = path.join(tempDir, 'skills');
const skillDirectoryPath = path.join(parentDirectoryPath, 'alpha');
const nodeModulesSkillPath = path.join(
parentDirectoryPath,
'node_modules',
'fake-skill',
);
const gitSkillPath = path.join(parentDirectoryPath, '.git', 'fake-skill');
const yarnCacheSkillPath = path.join(
parentDirectoryPath,
'.yarn',
'cache',
'fake-skill',
);

await fs.mkdir(skillDirectoryPath, { recursive: true });
await fs.mkdir(nodeModulesSkillPath, { recursive: true });
await fs.mkdir(gitSkillPath, { recursive: true });
await fs.mkdir(yarnCacheSkillPath, { recursive: true });
await fs.writeFile(path.join(skillDirectoryPath, 'SKILL.md'), 'content');
await fs.writeFile(
path.join(nodeModulesSkillPath, 'SKILL.md'),
'ignored',
);
await fs.writeFile(path.join(gitSkillPath, 'SKILL.md'), 'ignored');
await fs.writeFile(path.join(yarnCacheSkillPath, 'SKILL.md'), 'ignored');

const resolvedPaths = await resolveSkillInputPaths(['skills'], tempDir);

expect(resolvedPaths).toEqual([skillDirectoryPath]);
});
});

describe('when scanning a nested directory fails with a permission error', () => {
it('surfaces the filesystem error', async () => {
const parentDirectoryPath = path.join(tempDir, 'skills');
const accessibleSkillDirectoryPath = path.join(parentDirectoryPath, 'alpha');
const blockedDirectoryPath = path.join(parentDirectoryPath, 'blocked');
const originalReaddir = fs.readdir.bind(fs);

await fs.mkdir(accessibleSkillDirectoryPath, { recursive: true });
await fs.mkdir(blockedDirectoryPath, { recursive: true });
await fs.writeFile(
path.join(accessibleSkillDirectoryPath, 'SKILL.md'),
'alpha content',
);

jest.spyOn(fs, 'readdir').mockImplementation(
(async (...args: Parameters<typeof fs.readdir>) => {
const [targetPath] = args;

if (path.resolve(String(targetPath)) === blockedDirectoryPath) {
throw createPermissionDeniedError(blockedDirectoryPath);
}

return originalReaddir(...args);
}) as typeof fs.readdir,
);

await expect(resolveSkillInputPaths(['skills'], tempDir)).rejects.toMatchObject({
code: 'EACCES',
});
});
});

describe('when the input root cannot be read', () => {
it('surfaces the stat permission error', async () => {
const blockedDirectoryPath = path.join(tempDir, 'skills');
const originalStat = fs.stat.bind(fs);

await fs.mkdir(blockedDirectoryPath, { recursive: true });

jest.spyOn(fs, 'stat').mockImplementation(
(async (...args: Parameters<typeof fs.stat>) => {
const [targetPath] = args;

if (path.resolve(String(targetPath)) === blockedDirectoryPath) {
throw createPermissionDeniedError(blockedDirectoryPath);
}

return originalStat(...args);
}) as typeof fs.stat,
);

await expect(resolveSkillInputPaths(['skills'], tempDir)).rejects.toMatchObject({
code: 'EACCES',
});
});
});
});

describe('resolveSkillDirectoryRoot', () => {
let tempDir: string;

beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-root-test-'));
});

afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});

describe('when the input path points to a file inside a skill directory', () => {
it('walks up to the enclosing skill directory', async () => {
const skillDirectoryPath = path.join(tempDir, 'alpha');
const nestedFilePath = path.join(skillDirectoryPath, 'docs', 'guide.md');

await fs.mkdir(path.dirname(nestedFilePath), { recursive: true });
await fs.writeFile(path.join(skillDirectoryPath, 'SKILL.md'), 'content');
await fs.writeFile(nestedFilePath, 'guide');

const resolvedDirectoryPath = await resolveSkillDirectoryRoot(nestedFilePath);

expect(resolvedDirectoryPath).toBe(skillDirectoryPath);
});
});
});
Loading