diff --git a/src/commands/skillify.ts b/src/commands/skillify.ts index 8393d861..afb2c0a6 100644 --- a/src/commands/skillify.ts +++ b/src/commands/skillify.ts @@ -30,6 +30,7 @@ import { runPull, type PullSummary } from "../skillify/pull.js"; import { runPush } from "../skillify/push.js"; import { runUnpull } from "../skillify/unpull.js"; import { loadConfig } from "../config.js"; +import { resolveDirConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { runMineLocal } from "./mine-local.js"; import { renderSubcommandUsageBlock } from "../cli/skillify-spec.js"; @@ -221,11 +222,14 @@ async function pullSkills(args: string[]): Promise { else if (userOne) users = [userOne]; else if (usersMany) users = usersMany.split(",").map(s => s.trim()).filter(Boolean); - const config = loadConfig(); - if (!config) { + const base = loadConfig(); + if (!base) { console.error("Not logged in. Run: hivemind login"); process.exit(1); } + // Pull from the directory's routed workspace, symmetric with push and with + // where this tree's traces are captured (see src/dir-config.ts). + const config = resolveDirConfig(base, process.cwd()).config; const api = new DeeplakeApi( config.token, config.apiUrl, config.orgId, config.workspaceId, config.skillsTableName, ); @@ -285,10 +289,16 @@ async function pushSkills(args: string[]): Promise { throw new Error("Usage: hivemind skillify push [--from project|global] [--dry-run]"); } - const config = loadConfig(); - if (!config) { + const base = loadConfig(); + if (!base) { throw new Error("Not logged in. Run: hivemind login"); } + // Honor the nearest `.hivemind` so a push lands in the directory's routed + // workspace, not the global one — matching where this tree's session traces + // are captured. Without this, `skillify push` always wrote to the global + // workspace regardless of `.hivemind` (the org/workspace fields are identity; + // they apply to writes and reads alike — see src/dir-config.ts). + const config = resolveDirConfig(base, process.cwd()).config; const api = new DeeplakeApi( config.token, config.apiUrl, config.orgId, config.workspaceId, config.skillsTableName, ); diff --git a/tests/claude-code/skillify-dir-routing.test.ts b/tests/claude-code/skillify-dir-routing.test.ts new file mode 100644 index 00000000..0a9301fa --- /dev/null +++ b/tests/claude-code/skillify-dir-routing.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** + * `hivemind skillify push`/`pull` must route to the directory's `.hivemind` + * workspace, exactly like session capture does — otherwise a skill saved from a + * routed repo lands in the GLOBAL workspace (the bug DevOps hit: "saved a local + * skill, it appeared in the default workspace"). skillify.ts used `loadConfig()` + * raw with no `.hivemind` overlay; these tests pin the fix by asserting WHICH + * workspace the DeeplakeApi was constructed against — that URL path is where the + * write lands (deeplake-api.ts: /workspaces/{workspaceId}/tables/query). + * + * resolveDirConfig runs for REAL against the process cwd; only the config + * boundary and the API transport are mocked. + */ + +const h = vi.hoisted(() => ({ + ctorWorkspaces: [] as string[], + loadConfigMock: vi.fn(), +})); + +vi.mock("../../src/config.js", () => ({ loadConfig: h.loadConfigMock })); +vi.mock("../../src/deeplake-api.js", () => ({ + DeeplakeApi: class { + constructor(_token: string, _apiUrl: string, _orgId: string, workspaceId: string) { + h.ctorWorkspaces.push(workspaceId); + } + async query() { return []; } + }, +})); + +import { runSkillifyCommand } from "../../src/commands/skillify.js"; + +const GLOBAL_CONFIG = { + token: "tok", apiUrl: "https://api.test", orgId: "org", workspaceId: "global-ws", + userName: "tester", skillsTableName: "skills", codebaseTableName: "codebase", + tableName: "memory", sessionsTableName: "sessions", memoryPath: "/m", orgName: "org", +}; + +let root: string; +let originalCwd: string; + +function hivemind(dir: string, body: unknown): void { + writeFileSync(join(dir, ".hivemind"), JSON.stringify(body)); +} + +function writeSkill(dir: string, name: string): void { + const skillDir = join(dir, ".claude", "skills", name); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + ["---", `name: ${name}`, "description: a test skill", "version: 1", "author: tester", "---", "", "body", ""].join("\n"), + ); +} + +beforeEach(() => { + originalCwd = process.cwd(); + root = mkdtempSync(join(tmpdir(), "hivemind-skillify-route-")); + h.ctorWorkspaces.length = 0; + h.loadConfigMock.mockReset().mockReturnValue(GLOBAL_CONFIG); + delete process.env.HIVEMIND_ORG_ID; + delete process.env.HIVEMIND_WORKSPACE_ID; + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + process.chdir(originalCwd); + rmSync(root, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("skillify push routes through .hivemind", () => { + it("pushes to the directory's routed workspace, not the global one", async () => { + hivemind(root, { workspaceId: "workspace2" }); + writeSkill(root, "my-skill"); + process.chdir(root); + await runSkillifyCommand(["push", "my-skill", "--from", "project"]); + expect(h.ctorWorkspaces).toContain("workspace2"); + expect(h.ctorWorkspaces).not.toContain("global-ws"); + }); + + it("inherits the workspace from an ancestor .hivemind", async () => { + hivemind(root, { workspaceId: "client-work" }); + const leaf = join(root, "svc", "deep"); + mkdirSync(leaf, { recursive: true }); + writeSkill(leaf, "my-skill"); + process.chdir(leaf); + await runSkillifyCommand(["push", "my-skill", "--from", "project"]); + expect(h.ctorWorkspaces).toContain("client-work"); + }); + + it("still routes push under collect:false — collect gates capture, not identity", async () => { + hivemind(root, { workspaceId: "client-work", collect: false }); + writeSkill(root, "my-skill"); + process.chdir(root); + await runSkillifyCommand(["push", "my-skill", "--from", "project"]); + expect(h.ctorWorkspaces).toContain("client-work"); + }); + + it("falls back to the global workspace when no .hivemind applies", async () => { + writeSkill(root, "my-skill"); + process.chdir(root); + await runSkillifyCommand(["push", "my-skill", "--from", "project"]); + expect(h.ctorWorkspaces).toContain("global-ws"); + }); +}); + +describe("skillify pull routes through .hivemind", () => { + it("pulls from the directory's routed workspace", async () => { + hivemind(root, { workspaceId: "workspace2" }); + process.chdir(root); + await runSkillifyCommand(["pull"]); + expect(h.ctorWorkspaces).toContain("workspace2"); + expect(h.ctorWorkspaces).not.toContain("global-ws"); + }); + + it("falls back to the global workspace with no .hivemind", async () => { + process.chdir(root); + await runSkillifyCommand(["pull"]); + expect(h.ctorWorkspaces).toContain("global-ws"); + }); +});