Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 14 additions & 4 deletions src/commands/skillify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -221,11 +222,14 @@ async function pullSkills(args: string[]): Promise<void> {
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,
);
Expand Down Expand Up @@ -285,10 +289,16 @@ async function pushSkills(args: string[]): Promise<void> {
throw new Error("Usage: hivemind skillify push <skill-name> [--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,
);
Expand Down
125 changes: 125 additions & 0 deletions tests/claude-code/skillify-dir-routing.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});