···11+---
22+"@bomb.sh/tools": patch
33+---
44+55+Fixes `bsh sync` failing to find the project to sync into. It now resolves the project from your current directory rather than the package's install location.
+78-44
src/commands/sync.test.ts
···11-import { lstat, readlink } from 'node:fs/promises';
22-import { fileURLToPath } from 'node:url';
33-import { describe, it, expect } from 'vitest';
44-import { createFixture } from '../test-utils/index.ts';
55-import { copySkills } from './sync.ts';
11+import { lstat, readlink } from "node:fs/promises";
22+import { fileURLToPath } from "node:url";
33+import { describe, it, expect } from "vitest";
44+import { createFixture } from "../test-utils/index.ts";
55+import { copySkills, findProjectRoot } from "./sync.ts";
6677-describe('copySkills', () => {
88- it('symlinks each skill into the destination', async () => {
99- const fixture = await createFixture({
1010- 'source-skills': {
1111- build: {
1212- 'SKILL.md': '---\nname: build\ndescription: Build the project.\n---\nbody',
1313- },
1414- },
1515- });
77+describe("copySkills", () => {
88+ it("symlinks each skill into the destination", async () => {
99+ const fixture = await createFixture({
1010+ "source-skills": {
1111+ build: {
1212+ "SKILL.md": "---\nname: build\ndescription: Build the project.\n---\nbody",
1313+ },
1414+ },
1515+ });
16161717- const source = new URL('source-skills/', fixture.root);
1818- const dest = new URL('project/skills/', fixture.root);
1717+ const source = new URL("source-skills/", fixture.root);
1818+ const dest = new URL("project/skills/", fixture.root);
19192020- const skills = await copySkills({ source, dest });
2020+ const skills = await copySkills({ source, dest });
21212222- expect(skills).toEqual([{ name: 'build', description: 'Build the project.' }]);
2222+ expect(skills).toEqual([{ name: "build", description: "Build the project." }]);
23232424- // The destination entry must be a symlink, not a copy.
2525- const linkPath = fileURLToPath(new URL('build', dest));
2626- expect((await lstat(linkPath)).isSymbolicLink()).toBe(true);
2424+ // The destination entry must be a symlink, not a copy.
2525+ const linkPath = fileURLToPath(new URL("build", dest));
2626+ expect((await lstat(linkPath)).isSymbolicLink()).toBe(true);
27272828- // And it must resolve back to the source skill.
2929- expect(await readlink(linkPath)).toBe('../../source-skills/build');
2828+ // And it must resolve back to the source skill.
2929+ expect(await readlink(linkPath)).toBe("../../source-skills/build");
30303131- // Reading through the link reaches the real file.
3232- expect(await fixture.text('project/skills/build/SKILL.md')).toContain('name: build');
3333- });
3131+ // Reading through the link reaches the real file.
3232+ expect(await fixture.text("project/skills/build/SKILL.md")).toContain("name: build");
3333+ });
34343535- it('is idempotent and never touches the source on re-sync', async () => {
3636- const fixture = await createFixture({
3737- 'source-skills': {
3838- build: {
3939- 'SKILL.md': '---\nname: build\ndescription: Build the project.\n---\nbody',
4040- },
4141- },
4242- });
3535+ it("is idempotent and never touches the source on re-sync", async () => {
3636+ const fixture = await createFixture({
3737+ "source-skills": {
3838+ build: {
3939+ "SKILL.md": "---\nname: build\ndescription: Build the project.\n---\nbody",
4040+ },
4141+ },
4242+ });
43434444- const source = new URL('source-skills/', fixture.root);
4545- const dest = new URL('project/skills/', fixture.root);
4444+ const source = new URL("source-skills/", fixture.root);
4545+ const dest = new URL("project/skills/", fixture.root);
4646+4747+ const first = await copySkills({ source, dest });
4848+ // Re-running must not throw and must yield the same result.
4949+ const second = await copySkills({ source, dest });
5050+5151+ expect(second).toEqual(first);
5252+ // The source skill files must survive a re-sync.
5353+ expect(await fixture.text("source-skills/build/SKILL.md")).toContain("name: build");
5454+ expect(await fixture.text("project/skills/build/SKILL.md")).toContain("name: build");
5555+ });
5656+});
5757+5858+describe("findProjectRoot", () => {
5959+ it("finds the nearest package.json walking up from the start directory", async () => {
6060+ const fixture = await createFixture({
6161+ "package.json": { name: "my-app", version: "1.0.0" },
6262+ src: { nested: { "index.ts": "export const x = 1" } },
6363+ });
6464+6565+ const result = await findProjectRoot(fileURLToPath(new URL("src/nested", fixture.root)));
6666+6767+ expect(result).toEqual({ kind: "found", root: fixture.root });
6868+ });
46694747- const first = await copySkills({ source, dest });
4848- // Re-running must not throw and must yield the same result.
4949- const second = await copySkills({ source, dest });
7070+ it("reports self when the nearest package is @bomb.sh/tools", async () => {
7171+ const fixture = await createFixture({
7272+ "package.json": { name: "@bomb.sh/tools", version: "1.0.0" },
7373+ });
50745151- expect(second).toEqual(first);
5252- // The source skill files must survive a re-sync.
5353- expect(await fixture.text('source-skills/build/SKILL.md')).toContain('name: build');
5454- expect(await fixture.text('project/skills/build/SKILL.md')).toContain('name: build');
5555- });
7575+ const result = await findProjectRoot(fileURLToPath(fixture.root));
7676+7777+ expect(result).toEqual({ kind: "self" });
7878+ });
7979+8080+ it("reports not-found when no package.json exists in any parent", async () => {
8181+ const fixture = await createFixture({
8282+ src: { "index.ts": "export const x = 1" },
8383+ });
8484+8585+ // A nested directory in a fixture that has no package.json anywhere up to /tmp.
8686+ const result = await findProjectRoot(fileURLToPath(new URL("src", fixture.root)));
8787+8888+ expect(result).toEqual({ kind: "not-found" });
8989+ });
5690});
+31-16
src/commands/sync.ts
···11import { readlink, rm, symlink } from 'node:fs/promises';
22-import { findPackageJSON } from 'node:module';
32import { dirname, isAbsolute, relative, resolve } from 'node:path';
44-import { platform } from 'node:process';
33+import { cwd, platform } from 'node:process';
54import { fileURLToPath, pathToFileURL } from 'node:url';
65import { NodeHfs } from '@humanfs/node';
76import { parse } from 'ultramatter';
···1514const GITIGNORE_END = '# /bsh:skills';
16151716export async function sync(_ctx: CommandContext): Promise<void> {
1818- const parentPkg = findParentPackage();
1919- if (!parentPkg) {
2020- console.info('Skipping sync — no parent project found (running inside @bomb.sh/tools?)');
1717+ const project = await findProjectRoot(cwd());
1818+ if (project.kind === 'self') {
1919+ console.info('Skipping sync — running inside @bomb.sh/tools.');
2020+ return;
2121+ }
2222+ if (project.kind === 'not-found') {
2323+ console.info(`Skipping sync — no package.json found in ${cwd()} or any parent directory.`);
2124 return;
2225 }
23262424- const root = pathToFileURL(`${dirname(parentPkg)}/`);
2727+ const root = project.root;
2528 const source = new URL('../../skills/', import.meta.url);
26292730 if (!(await hfs.isDirectory(source))) {
···174177 return { name, description };
175178}
176179177177-function findParentPackage(): string | null {
178178- const ownPkg = findPackageJSON(import.meta.url);
179179- if (!ownPkg) return null;
180180+type ProjectLookup = { kind: 'found'; root: URL } | { kind: 'self' } | { kind: 'not-found' };
180181181181- let cursor = dirname(dirname(ownPkg));
182182- while (cursor !== dirname(cursor)) {
183183- const candidate = findPackageJSON(pathToFileURL(`${cursor}/`));
184184- if (!candidate) return null;
185185- if (candidate !== ownPkg) return candidate;
186186- cursor = dirname(dirname(candidate));
182182+/**
183183+ * Locate the project to sync into by walking up from `start` to the nearest
184184+ * `package.json`.
185185+ *
186186+ * This is based on the working directory the user runs `bsh sync` from — not the
187187+ * install location of `@bomb.sh/tools`, which under pnpm resolves to an unrelated
188188+ * path inside the virtual store. Returns `self` when the nearest package is
189189+ * `@bomb.sh/tools` so the command is a no-op when run inside this repo.
190190+ */
191191+export async function findProjectRoot(start: string): Promise<ProjectLookup> {
192192+ let dir = start;
193193+ while (true) {
194194+ const pkgUrl = new URL('package.json', pathToFileURL(`${dir}/`));
195195+ if (await hfs.isFile(pkgUrl)) {
196196+ const pkg = (await hfs.json(pkgUrl)) as { name?: string } | undefined;
197197+ if (pkg?.name === '@bomb.sh/tools') return { kind: 'self' };
198198+ return { kind: 'found', root: new URL('./', pkgUrl) };
199199+ }
200200+ const parent = dirname(dir);
201201+ if (parent === dir) return { kind: 'not-found' };
202202+ dir = parent;
187203 }
188188- return null;
189204}