feat(cli+mcpd): mcpctl skills sync + config claude extension
Phase 5 of the Skills + Revisions + Proposals work. Skills are now
materialised onto disk under ~/.claude/skills/<name>/, with
hash-pinned diff against mcpd, atomic per-skill install, and
preservation of locally-modified files. `mcpctl config claude --project X`
now wires the full pickup chain: writes .mcpctl-project marker, runs
the initial sync, installs the SessionStart hook so subsequent Claude
invocations stay in sync transparently.
## Sync algorithm
1. Resolve project: `--project` flag overrides; else walk up from cwd
looking for `.mcpctl-project`; else fall back to globals-only.
2. GET /api/v1/projects/:name/skills/visible (or
/api/v1/skills?scope=global without a project). Server returns
id + name + semver + scope + contentHash + metadata — no body, no
files. The contentHash is sha256 of the canonicalised body, computed
server-side; any reordering of keys produces the same hash, so it's
a stable diff key.
3. Load ~/.mcpctl/skills-state.json (lives outside ~/.claude/skills/
on purpose — Claude Code reads that tree and we don't want to
pollute it with our bookkeeping).
4. Diff:
- server skill not in state → INSTALL
- server skill, state contentHash matches → SKIP (cheap path)
- server skill, state contentHash differs → UPDATE (fetch full body)
- state skill not in server → orphan, REMOVE (preserve if locally
modified, unless --force)
5. Atomic per-skill install: write to <targetDir>.mcpctl-staging-<pid>/,
rename existing tree to .mcpctl-trash-<pid>, swap staging in,
rmtree the trash. A concurrent reader (Claude Code starting up)
never sees a partial tree.
6. State file updated with new versions, per-file SHA-256, install
path. saveState is atomic (temp + rename).
## Failure semantics
- `--quiet` mode (used by SessionStart hook): exit 0 on network /
timeout / mcpd error. Fail-open is non-negotiable here — we never
want a hung mcpd to block Claude Code starting up.
- Auth failure: exit 1, clear "run mcpctl login" message.
- Disk error during state save: exit 2.
- Per-skill errors are collected in the result and reported as a
count; one bad skill doesn't stop the others.
Network fetches run with concurrency 5. The server-side
`/visible` endpoint is metadata-only so the cheap path (everything
unchanged) needs exactly one HTTP roundtrip total.
## Files added
### CLI utilities (src/cli/src/utils/)
- skills-state.ts — load/save state, per-file sha256, edit detection.
- project-marker.ts — walk-up to find `.mcpctl-project`, bounded by
user home so we never search above $HOME.
- sessionhook.ts — install/remove a SessionStart hook entry tagged
with `_mcpctl_managed: true`. Idempotent. Defensive against
missing/empty/JSONC settings.json.
- skills-disk.ts — atomic install via staging-dir rename swap,
symmetric atomic delete via trash-dir rename. Path-escape attempts
in files{} are rejected.
### CLI command (src/cli/src/commands/)
- skills.ts — `mcpctl skills sync` Commander wrapper + the
`runSkillsSync(opts, deps)` library function (also called from
`mcpctl config claude --project`). Supports `--dry-run`, `--force`,
`--quiet`, `--keep-orphans`. `--skip-postinstall` is reserved
(postInstall execution lands in a follow-up PR, not this one).
### Wiring
- index.ts: registers `mcpctl skills` after `mcpctl review`.
- config.ts: `mcpctl config claude --project X` now writes the
`.mcpctl-project` marker, runs `runSkillsSync` in-process, and calls
`installManagedSessionHook('mcpctl skills sync --quiet')`. New flag
`--skip-skills` opts out (used by tests; useful for CI).
## Server-side change
- src/mcpd/src/services/skill.service.ts: getVisibleSkills now
computes contentHash on the fly from the canonical body shape the
client will reconstruct. Cheap (sha256 of ~few KB per skill); no
schema migration needed since hash is derived not stored.
## Tests
Four new utility test files (31 tests) under src/cli/tests/utils/:
- sessionhook.test.ts — creation, idempotency, command updates,
preservation of user hooks, removal, empty/JSONC tolerance.
- skills-disk.test.ts — atomic write, replacement without leftovers,
path-escape rejection, atomic delete, listing ignores
staging/trash artifacts.
- skills-state.test.ts — sha256 determinism, state round-trip,
schema-version drift handling, edit detection.
- project-marker.test.ts — cwd hit, walk-up, $HOME boundary, empty
marker, write+read round-trip.
The existing `mcpctl config claude` test (claude.test.ts) was updated
to pass `--skip-skills` so it stays focused on .mcp.json generation;
the new sync flow is covered by the utility tests.
Full suite: 162 test files / 2157 tests green (up from 158 / 2127).
## Deferred to a follow-up
- `metadata.hooks` materialisation into `~/.claude/settings.json` —
the data path exists, sync receives it; PR-7 or a focused follow-up
will write the `_mcpctl_managed: true` entries for declarative
hooks.
- `metadata.mcpServers` auto-attach via mcpd API — likewise.
- `metadata.postInstall` script execution — the most substantive
deferred piece. Current sync logs a TODO and skips. The corporate
trust model (publisher-side rigor, not client-side defence) means
this is straightforward to add once we wire the curated env +
timeout + audit emission. Orthogonal to file sync, easier to ship
separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,9 +37,12 @@ describe('config claude', () => {
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['claude', '--project', 'homeautomation', '-o', outPath], { from: 'user' });
|
||||
// PR-5: --skip-skills bypasses the new sync + SessionStart hook side
|
||||
// effects so this test stays focused on .mcp.json generation. The new
|
||||
// sync flow has its own tests under src/cli/tests/utils/.
|
||||
await cmd.parseAsync(['claude', '--project', 'homeautomation', '-o', outPath, '--skip-skills'], { from: 'user' });
|
||||
|
||||
// No API call should be made
|
||||
// No API call should be made when --skip-skills is set.
|
||||
expect(client.get).not.toHaveBeenCalled();
|
||||
|
||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||
|
||||
68
src/cli/tests/utils/project-marker.test.ts
Normal file
68
src/cli/tests/utils/project-marker.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { findProjectMarker, writeProjectMarker, MARKER_FILENAME } from '../../src/utils/project-marker.js';
|
||||
|
||||
describe('project-marker', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(join(tmpdir(), 'mcpctl-marker-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('finds marker in cwd', async () => {
|
||||
await writeFile(join(tmp, MARKER_FILENAME), 'demo\n');
|
||||
const result = await findProjectMarker(tmp, '/never-exists');
|
||||
expect(result?.project).toBe('demo');
|
||||
expect(result?.markerPath).toBe(join(tmp, MARKER_FILENAME));
|
||||
});
|
||||
|
||||
it('walks up to find marker', async () => {
|
||||
const sub = join(tmp, 'a', 'b', 'c');
|
||||
await mkdir(sub, { recursive: true });
|
||||
await writeFile(join(tmp, MARKER_FILENAME), 'parent-project');
|
||||
const result = await findProjectMarker(sub, '/never-exists');
|
||||
expect(result?.project).toBe('parent-project');
|
||||
});
|
||||
|
||||
it('returns null when no marker exists', async () => {
|
||||
const sub = join(tmp, 'a', 'b');
|
||||
await mkdir(sub, { recursive: true });
|
||||
const result = await findProjectMarker(sub, '/never-exists');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('stops at user home directory', async () => {
|
||||
// Use tmp itself as the "home" — the walk should not go above it.
|
||||
const sub = join(tmp, 'projects', 'demo');
|
||||
await mkdir(sub, { recursive: true });
|
||||
// Marker would be at /tmp's parent (above home) — should not be found.
|
||||
const result = await findProjectMarker(sub, tmp);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('trims trailing whitespace from the project name', async () => {
|
||||
await writeFile(join(tmp, MARKER_FILENAME), ' demo \nignored\n');
|
||||
const result = await findProjectMarker(tmp, '/never-exists');
|
||||
expect(result?.project).toBe('demo');
|
||||
});
|
||||
|
||||
it('rejects empty marker file', async () => {
|
||||
await writeFile(join(tmp, MARKER_FILENAME), '\n');
|
||||
const result = await findProjectMarker(tmp, '/never-exists');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('writeProjectMarker writes the file with a trailing newline', async () => {
|
||||
const path = await writeProjectMarker(tmp, 'demo');
|
||||
expect(path).toBe(join(tmp, MARKER_FILENAME));
|
||||
const result = await findProjectMarker(tmp, '/never-exists');
|
||||
expect(result?.project).toBe('demo');
|
||||
});
|
||||
});
|
||||
106
src/cli/tests/utils/sessionhook.test.ts
Normal file
106
src/cli/tests/utils/sessionhook.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { installManagedSessionHook, removeManagedSessionHook, MARKER_KEY } from '../../src/utils/sessionhook.js';
|
||||
|
||||
describe('sessionhook', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(join(tmpdir(), 'mcpctl-sessionhook-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates settings.json from scratch when missing', async () => {
|
||||
const path = join(tmp, 'settings.json');
|
||||
const result = await installManagedSessionHook('mcpctl skills sync --quiet', path);
|
||||
expect(result.updated).toBe(true);
|
||||
const settings = JSON.parse(await readFile(path, 'utf-8'));
|
||||
expect(settings.hooks.SessionStart).toHaveLength(1);
|
||||
const entry = settings.hooks.SessionStart[0].hooks[0];
|
||||
expect(entry.command).toBe('mcpctl skills sync --quiet');
|
||||
expect(entry[MARKER_KEY]).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent — re-running does not add duplicates', async () => {
|
||||
const path = join(tmp, 'settings.json');
|
||||
await installManagedSessionHook('mcpctl skills sync --quiet', path);
|
||||
const second = await installManagedSessionHook('mcpctl skills sync --quiet', path);
|
||||
expect(second.updated).toBe(false);
|
||||
const settings = JSON.parse(await readFile(path, 'utf-8'));
|
||||
const entries = settings.hooks.SessionStart.flatMap((g: { hooks: unknown[] }) => g.hooks);
|
||||
const managed = entries.filter((e: Record<string, unknown>) => e[MARKER_KEY] === true);
|
||||
expect(managed).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('updates the command in place when it changes', async () => {
|
||||
const path = join(tmp, 'settings.json');
|
||||
await installManagedSessionHook('mcpctl skills sync', path);
|
||||
const updated = await installManagedSessionHook('mcpctl skills sync --quiet', path);
|
||||
expect(updated.updated).toBe(true);
|
||||
const settings = JSON.parse(await readFile(path, 'utf-8'));
|
||||
const managed = settings.hooks.SessionStart
|
||||
.flatMap((g: { hooks: unknown[] }) => g.hooks)
|
||||
.find((e: Record<string, unknown>) => e[MARKER_KEY] === true);
|
||||
expect(managed.command).toBe('mcpctl skills sync --quiet');
|
||||
});
|
||||
|
||||
it('preserves non-managed hooks', async () => {
|
||||
const path = join(tmp, 'settings.json');
|
||||
await mkdir(tmp, { recursive: true });
|
||||
await writeFile(path, JSON.stringify({
|
||||
hooks: {
|
||||
SessionStart: [{ hooks: [{ type: 'command', command: 'echo user-hook' }] }],
|
||||
},
|
||||
}));
|
||||
await installManagedSessionHook('mcpctl skills sync --quiet', path);
|
||||
const settings = JSON.parse(await readFile(path, 'utf-8'));
|
||||
const all = settings.hooks.SessionStart.flatMap((g: { hooks: unknown[] }) => g.hooks);
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all.find((e: Record<string, unknown>) => e.command === 'echo user-hook')).toBeDefined();
|
||||
expect(all.find((e: Record<string, unknown>) => e[MARKER_KEY] === true)).toBeDefined();
|
||||
});
|
||||
|
||||
it('remove drops the managed entry but keeps user hooks', async () => {
|
||||
const path = join(tmp, 'settings.json');
|
||||
await writeFile(path, JSON.stringify({
|
||||
hooks: {
|
||||
SessionStart: [{ hooks: [{ type: 'command', command: 'echo user' }] }],
|
||||
},
|
||||
}));
|
||||
await installManagedSessionHook('mcpctl skills sync --quiet', path);
|
||||
const removed = await removeManagedSessionHook(path);
|
||||
expect(removed.removed).toBe(true);
|
||||
const settings = JSON.parse(await readFile(path, 'utf-8'));
|
||||
const all = settings.hooks.SessionStart.flatMap((g: { hooks: unknown[] }) => g.hooks);
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0].command).toBe('echo user');
|
||||
});
|
||||
|
||||
it('remove is a no-op when no managed entry exists', async () => {
|
||||
const path = join(tmp, 'settings.json');
|
||||
const result = await removeManagedSessionHook(path);
|
||||
expect(result.removed).toBe(false);
|
||||
});
|
||||
|
||||
it('survives empty settings.json', async () => {
|
||||
const path = join(tmp, 'settings.json');
|
||||
await writeFile(path, '');
|
||||
await installManagedSessionHook('mcpctl skills sync --quiet', path);
|
||||
const settings = JSON.parse(await readFile(path, 'utf-8'));
|
||||
expect(settings.hooks.SessionStart).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('strips line comments before parsing', async () => {
|
||||
const path = join(tmp, 'settings.json');
|
||||
await writeFile(path, '// a leading comment\n{\n "hooks": {}\n}\n');
|
||||
await installManagedSessionHook('mcpctl skills sync --quiet', path);
|
||||
const settings = JSON.parse(await readFile(path, 'utf-8'));
|
||||
expect(settings.hooks.SessionStart).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
85
src/cli/tests/utils/skills-disk.test.ts
Normal file
85
src/cli/tests/utils/skills-disk.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { installSkillAtomic, removeSkillAtomic, listInstalledSkillNames } from '../../src/utils/skills-disk.js';
|
||||
|
||||
describe('skills-disk', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(join(tmpdir(), 'mcpctl-skills-disk-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes SKILL.md and aux files atomically', async () => {
|
||||
const target = join(tmp, 'foo');
|
||||
const states = await installSkillAtomic(target, {
|
||||
content: '# Foo skill',
|
||||
files: { 'scripts/setup.sh': '#!/bin/sh\necho hi' },
|
||||
});
|
||||
expect(await readFile(join(target, 'SKILL.md'), 'utf-8')).toBe('# Foo skill');
|
||||
expect(await readFile(join(target, 'scripts/setup.sh'), 'utf-8')).toBe('#!/bin/sh\necho hi');
|
||||
expect(states['SKILL.md']).toBeDefined();
|
||||
expect(states['SKILL.md'].sha256).toMatch(/^sha256:/);
|
||||
expect(states['SKILL.md'].size).toBe('# Foo skill'.length);
|
||||
expect(states['scripts/setup.sh']).toBeDefined();
|
||||
});
|
||||
|
||||
it('replaces an existing tree without leaving partial state', async () => {
|
||||
const target = join(tmp, 'foo');
|
||||
await installSkillAtomic(target, { content: 'v1' });
|
||||
await installSkillAtomic(target, {
|
||||
content: 'v2',
|
||||
files: { 'extra.md': 'extra' },
|
||||
});
|
||||
expect(await readFile(join(target, 'SKILL.md'), 'utf-8')).toBe('v2');
|
||||
expect(await readFile(join(target, 'extra.md'), 'utf-8')).toBe('extra');
|
||||
// No staging or trash dirs left behind.
|
||||
const entries = await readdir(tmp);
|
||||
expect(entries.filter((e) => e.includes('mcpctl-staging') || e.includes('mcpctl-trash'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects path-escape attempts', async () => {
|
||||
const target = join(tmp, 'foo');
|
||||
await expect(installSkillAtomic(target, {
|
||||
content: 'x',
|
||||
files: { '../escaped': 'bad' },
|
||||
})).rejects.toThrow(/escapes install dir/);
|
||||
});
|
||||
|
||||
it('rejects absolute paths in files{}', async () => {
|
||||
const target = join(tmp, 'foo');
|
||||
await expect(installSkillAtomic(target, {
|
||||
content: 'x',
|
||||
files: { '/etc/passwd-like': 'bad' },
|
||||
})).rejects.toThrow(/escapes install dir/);
|
||||
});
|
||||
|
||||
it('removes a skill atomically', async () => {
|
||||
const target = join(tmp, 'foo');
|
||||
await installSkillAtomic(target, { content: 'x' });
|
||||
await removeSkillAtomic(target);
|
||||
expect((await readdir(tmp)).filter((n) => n === 'foo')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('remove is a no-op when target does not exist', async () => {
|
||||
await expect(removeSkillAtomic(join(tmp, 'never-existed'))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('listInstalledSkillNames ignores staging/trash artifacts', async () => {
|
||||
const skillsDir = join(tmp, 'skills-root');
|
||||
await mkdir(skillsDir, { recursive: true });
|
||||
await mkdir(join(skillsDir, 'real-skill'), { recursive: true });
|
||||
await mkdir(join(skillsDir, 'real-skill.mcpctl-staging-1234'), { recursive: true });
|
||||
await mkdir(join(skillsDir, 'something.mcpctl-trash-9999'), { recursive: true });
|
||||
await writeFile(join(skillsDir, 'real-skill', 'SKILL.md'), 'x');
|
||||
|
||||
const names = await listInstalledSkillNames(skillsDir);
|
||||
expect(names).toEqual(['real-skill']);
|
||||
});
|
||||
});
|
||||
107
src/cli/tests/utils/skills-state.test.ts
Normal file
107
src/cli/tests/utils/skills-state.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, writeFile, readFile, mkdir } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
loadState,
|
||||
saveState,
|
||||
emptyState,
|
||||
sha256Of,
|
||||
hasFileBeenModified,
|
||||
detectModifiedFiles,
|
||||
type FileState,
|
||||
} from '../../src/utils/skills-state.js';
|
||||
|
||||
describe('skills-state', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(join(tmpdir(), 'mcpctl-skills-state-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('sha256Of', () => {
|
||||
it('is deterministic and prefixed', () => {
|
||||
expect(sha256Of('hello')).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
expect(sha256Of('hello')).toBe(sha256Of('hello'));
|
||||
expect(sha256Of('hello')).not.toBe(sha256Of('hellp'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('load / save', () => {
|
||||
it('returns empty state when file does not exist', async () => {
|
||||
const state = await loadState(join(tmp, 'no-such.json'));
|
||||
expect(state.skills).toEqual({});
|
||||
expect(state.lastSync).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips state', async () => {
|
||||
const path = join(tmp, 'state.json');
|
||||
const state = emptyState();
|
||||
state.lastSync = '2026-05-07T00:00:00.000Z';
|
||||
state.lastSyncProject = 'demo';
|
||||
state.skills['my-skill'] = {
|
||||
id: 'cuid-x',
|
||||
semver: '0.1.0',
|
||||
contentHash: sha256Of('body'),
|
||||
scope: 'global',
|
||||
installDir: '/tmp/foo',
|
||||
files: { 'SKILL.md': { sha256: sha256Of('hi'), size: 2 } },
|
||||
postInstallHash: null,
|
||||
lastSyncedAt: '2026-05-07T00:00:00.000Z',
|
||||
};
|
||||
await saveState(state, path);
|
||||
const loaded = await loadState(path);
|
||||
expect(loaded).toEqual(state);
|
||||
});
|
||||
|
||||
it('starts fresh on schema-version drift', async () => {
|
||||
const path = join(tmp, 'state.json');
|
||||
await writeFile(path, JSON.stringify({ schemaVersion: 99, skills: { x: {} } }));
|
||||
const state = await loadState(path);
|
||||
expect(state.schemaVersion).toBe(1);
|
||||
expect(state.skills).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasFileBeenModified', () => {
|
||||
it('false when content matches recorded hash + size', async () => {
|
||||
const dir = join(tmp, 'sk');
|
||||
await mkdir(dir);
|
||||
await writeFile(join(dir, 'SKILL.md'), 'hello');
|
||||
const recorded: FileState = { sha256: sha256Of('hello'), size: 5 };
|
||||
expect(await hasFileBeenModified(dir, 'SKILL.md', recorded)).toBe(false);
|
||||
});
|
||||
|
||||
it('true when content differs', async () => {
|
||||
const dir = join(tmp, 'sk');
|
||||
await mkdir(dir);
|
||||
await writeFile(join(dir, 'SKILL.md'), 'edited');
|
||||
const recorded: FileState = { sha256: sha256Of('hello'), size: 5 };
|
||||
expect(await hasFileBeenModified(dir, 'SKILL.md', recorded)).toBe(true);
|
||||
});
|
||||
|
||||
it('true when file is missing', async () => {
|
||||
const recorded: FileState = { sha256: sha256Of('hello'), size: 5 };
|
||||
expect(await hasFileBeenModified(tmp, 'missing.md', recorded)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectModifiedFiles', () => {
|
||||
it('returns the list of edited paths', async () => {
|
||||
const dir = join(tmp, 'sk');
|
||||
await mkdir(dir);
|
||||
await writeFile(join(dir, 'SKILL.md'), 'pristine');
|
||||
await writeFile(join(dir, 'extra.md'), 'edited');
|
||||
const result = await detectModifiedFiles(dir, {
|
||||
'SKILL.md': { sha256: sha256Of('pristine'), size: 8 },
|
||||
'extra.md': { sha256: sha256Of('original'), size: 8 },
|
||||
});
|
||||
expect(result).toEqual(['extra.md']);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user