@uluops/setup 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -0
- package/assets/agents/api-contract-validator-agent.md +960 -0
- package/assets/agents/aristotle-analyst-agent.md +705 -0
- package/assets/agents/aristotle-explorer-agent.md +152 -0
- package/assets/agents/aristotle-forecaster-agent.md +666 -0
- package/assets/agents/aristotle-validator-agent.md +667 -0
- package/assets/agents/assumption-excavator-agent.md +1354 -0
- package/assets/agents/code-auditor-agent.md +1061 -0
- package/assets/agents/code-optimizer-agent.md +876 -0
- package/assets/agents/code-validator-agent.md +846 -0
- package/assets/agents/docs-validator-agent.md +490 -0
- package/assets/agents/frontend-validator-agent.md +844 -0
- package/assets/agents/mcp-validator-agent.md +827 -0
- package/assets/agents/pre-implementation-architect-agent.md +1036 -0
- package/assets/agents/prompt-engineer-agent.md +1158 -0
- package/assets/agents/prompt-pattern-analyzer-agent.md +907 -0
- package/assets/agents/prompt-quality-validator-agent.md +1018 -0
- package/assets/agents/public-interface-validator-agent.md +951 -0
- package/assets/agents/release-readiness-agent.md +482 -0
- package/assets/agents/security-analyst-agent.md +1093 -0
- package/assets/agents/test-architect-agent.md +861 -0
- package/assets/agents/type-safety-validator-agent.md +932 -0
- package/assets/agents/workflow-synthesis-agent.md +836 -0
- package/assets/commands/agents/api-contract.md +135 -0
- package/assets/commands/agents/architect.md +135 -0
- package/assets/commands/agents/aristotle-analyst.md +115 -0
- package/assets/commands/agents/aristotle-explorer.md +92 -0
- package/assets/commands/agents/aristotle-forecaster.md +114 -0
- package/assets/commands/agents/aristotle-validator.md +114 -0
- package/assets/commands/agents/assumption-excavator.md +114 -0
- package/assets/commands/agents/audit.md +136 -0
- package/assets/commands/agents/docs-validate.md +133 -0
- package/assets/commands/agents/frontend.md +135 -0
- package/assets/commands/agents/mcp-validate.md +136 -0
- package/assets/commands/agents/optimize.md +133 -0
- package/assets/commands/agents/pattern-analyzer.md +126 -0
- package/assets/commands/agents/prompt-quality.md +134 -0
- package/assets/commands/agents/prompt-validate.md +135 -0
- package/assets/commands/agents/public-interface.md +134 -0
- package/assets/commands/agents/release.md +135 -0
- package/assets/commands/agents/security.md +137 -0
- package/assets/commands/agents/test-review.md +136 -0
- package/assets/commands/agents/type-safety.md +135 -0
- package/assets/commands/agents/validate.md +134 -0
- package/assets/commands/agents/workflow-synthesis.md +101 -0
- package/assets/commands/workflows/aristotle.md +543 -0
- package/assets/commands/workflows/post-implementation.md +577 -0
- package/assets/commands/workflows/pre-implementation.md +670 -0
- package/assets/commands/workflows/prompt-audit.md +754 -0
- package/assets/commands/workflows/ship.md +721 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +436 -0
- package/dist/lib/config-merger.d.ts +26 -0
- package/dist/lib/config-merger.js +63 -0
- package/dist/lib/file-ops.d.ts +23 -0
- package/dist/lib/file-ops.js +86 -0
- package/dist/lib/hash.d.ts +1 -0
- package/dist/lib/hash.js +4 -0
- package/dist/lib/manifest.d.ts +16 -0
- package/dist/lib/manifest.js +34 -0
- package/dist/lib/paths.d.ts +14 -0
- package/dist/lib/paths.js +49 -0
- package/dist/lib/settings-merger.d.ts +43 -0
- package/dist/lib/settings-merger.js +91 -0
- package/dist/steps/agents.d.ts +8 -0
- package/dist/steps/agents.js +14 -0
- package/dist/steps/auth.d.ts +12 -0
- package/dist/steps/auth.js +80 -0
- package/dist/steps/commands.d.ts +9 -0
- package/dist/steps/commands.js +69 -0
- package/dist/steps/detect.d.ts +9 -0
- package/dist/steps/detect.js +30 -0
- package/dist/steps/mcp.d.ts +6 -0
- package/dist/steps/mcp.js +40 -0
- package/dist/steps/metrics.d.ts +22 -0
- package/dist/steps/metrics.js +176 -0
- package/dist/steps/shell.d.ts +2 -0
- package/dist/steps/shell.js +48 -0
- package/dist/steps/signup.d.ts +13 -0
- package/dist/steps/signup.js +92 -0
- package/dist/steps/verify.d.ts +10 -0
- package/dist/steps/verify.js +184 -0
- package/dist/test/auth.test.d.ts +1 -0
- package/dist/test/auth.test.js +43 -0
- package/dist/test/config-io.test.d.ts +1 -0
- package/dist/test/config-io.test.js +56 -0
- package/dist/test/config-merger.test.d.ts +1 -0
- package/dist/test/config-merger.test.js +94 -0
- package/dist/test/detect.test.d.ts +1 -0
- package/dist/test/detect.test.js +25 -0
- package/dist/test/file-ops.test.d.ts +1 -0
- package/dist/test/file-ops.test.js +100 -0
- package/dist/test/hash.test.d.ts +1 -0
- package/dist/test/hash.test.js +14 -0
- package/dist/test/manifest.test.d.ts +1 -0
- package/dist/test/manifest.test.js +78 -0
- package/dist/test/paths.test.d.ts +1 -0
- package/dist/test/paths.test.js +30 -0
- package/dist/test/settings-merger.test.d.ts +1 -0
- package/dist/test/settings-merger.test.js +167 -0
- package/dist/test/shell-profile.test.d.ts +1 -0
- package/dist/test/shell-profile.test.js +40 -0
- package/dist/test/shell.test.d.ts +1 -0
- package/dist/test/shell.test.js +71 -0
- package/dist/test/signup.test.d.ts +1 -0
- package/dist/test/signup.test.js +83 -0
- package/package.json +36 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mergeUluopsMcp, removeUluopsMcp } from "../lib/config-merger.js";
|
|
3
|
+
describe("mergeUluopsMcp", () => {
|
|
4
|
+
it("adds both MCP servers to empty config", () => {
|
|
5
|
+
const result = mergeUluopsMcp({}, "ulr_test123");
|
|
6
|
+
expect(result.mcpServers).toBeDefined();
|
|
7
|
+
expect(result.mcpServers["uluops-tracker"]).toMatchObject({
|
|
8
|
+
command: "npx",
|
|
9
|
+
args: ["-y", "uluops-tracker-mcp-client"],
|
|
10
|
+
env: {
|
|
11
|
+
ULUOPS_TRACKER_API_URL: "https://api.uluops.ai/api/v1",
|
|
12
|
+
ULUOPS_TRACKER_API_KEY: "ulr_test123",
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
expect(result.mcpServers["uluops-registry"]).toMatchObject({
|
|
16
|
+
command: "npx",
|
|
17
|
+
args: ["-y", "uluops-registry-mcp-client"],
|
|
18
|
+
env: {
|
|
19
|
+
ULUOPS_REGISTRY_URL: "https://api.uluops.ai/api/v1/registry",
|
|
20
|
+
ULUOPS_API_KEY: "ulr_test123",
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
it("preserves existing non-UluOps MCP servers", () => {
|
|
25
|
+
const config = {
|
|
26
|
+
mcpServers: {
|
|
27
|
+
"other-server": { command: "node", args: ["./other.js"], env: {} },
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
const result = mergeUluopsMcp(config, "ulr_abc");
|
|
31
|
+
expect(result.mcpServers["other-server"]).toBeDefined();
|
|
32
|
+
expect(result.mcpServers["uluops-tracker"]).toBeDefined();
|
|
33
|
+
});
|
|
34
|
+
it("preserves all top-level non-mcpServers keys", () => {
|
|
35
|
+
const config = {
|
|
36
|
+
numStartups: 42,
|
|
37
|
+
tipsHistory: ["tip1"],
|
|
38
|
+
mcpServers: {},
|
|
39
|
+
};
|
|
40
|
+
const result = mergeUluopsMcp(config, "ulr_abc");
|
|
41
|
+
expect(result.numStartups).toBe(42);
|
|
42
|
+
expect(result.tipsHistory).toEqual(["tip1"]);
|
|
43
|
+
});
|
|
44
|
+
it("overwrites existing UluOps servers with new API key", () => {
|
|
45
|
+
const config = {
|
|
46
|
+
mcpServers: {
|
|
47
|
+
"uluops-registry": {
|
|
48
|
+
command: "npx",
|
|
49
|
+
args: ["-y", "uluops-registry-mcp-client"],
|
|
50
|
+
env: { ULUOPS_API_KEY: "ulr_old", ULUOPS_REGISTRY_URL: "https://api.uluops.ai/api/v1/registry" },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
const result = mergeUluopsMcp(config, "ulr_new");
|
|
55
|
+
expect(result.mcpServers["uluops-registry"].env["ULUOPS_API_KEY"]).toBe("ulr_new");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
describe("removeUluopsMcp", () => {
|
|
59
|
+
it("removes both UluOps servers", () => {
|
|
60
|
+
const config = {
|
|
61
|
+
mcpServers: {
|
|
62
|
+
"uluops-tracker": { command: "npx", args: [], env: {} },
|
|
63
|
+
"uluops-registry": { command: "npx", args: [], env: {} },
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const result = removeUluopsMcp(config);
|
|
67
|
+
expect(result.mcpServers).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
it("preserves non-UluOps servers when removing", () => {
|
|
70
|
+
const config = {
|
|
71
|
+
mcpServers: {
|
|
72
|
+
"uluops-tracker": { command: "npx", args: [], env: {} },
|
|
73
|
+
"other-server": { command: "node", args: [], env: {} },
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
const result = removeUluopsMcp(config);
|
|
77
|
+
expect(result.mcpServers["other-server"]).toBeDefined();
|
|
78
|
+
expect(result.mcpServers["uluops-tracker"]).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
it("preserves top-level keys other than mcpServers", () => {
|
|
81
|
+
const config = {
|
|
82
|
+
numStartups: 5,
|
|
83
|
+
mcpServers: {
|
|
84
|
+
"uluops-tracker": { command: "npx", args: [], env: {} },
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const result = removeUluopsMcp(config);
|
|
88
|
+
expect(result.numStartups).toBe(5);
|
|
89
|
+
});
|
|
90
|
+
it("handles config with no mcpServers key", () => {
|
|
91
|
+
const result = removeUluopsMcp({ numStartups: 1 });
|
|
92
|
+
expect(result.mcpServers).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { detect } from "../steps/detect.js";
|
|
3
|
+
describe("detect", () => {
|
|
4
|
+
it("returns a valid Environment object", async () => {
|
|
5
|
+
const env = await detect();
|
|
6
|
+
expect(env).toHaveProperty("os");
|
|
7
|
+
expect(env).toHaveProperty("isWsl");
|
|
8
|
+
expect(env).toHaveProperty("shell");
|
|
9
|
+
expect(env).toHaveProperty("shellProfile");
|
|
10
|
+
expect(env).toHaveProperty("nodeVersion");
|
|
11
|
+
expect(env).toHaveProperty("claudeHomeExists");
|
|
12
|
+
});
|
|
13
|
+
it("nodeVersion matches process.version", async () => {
|
|
14
|
+
const env = await detect();
|
|
15
|
+
expect(env.nodeVersion).toBe(process.version);
|
|
16
|
+
});
|
|
17
|
+
it("os is one of the supported platforms", async () => {
|
|
18
|
+
const env = await detect();
|
|
19
|
+
expect(["linux", "darwin", "win32"]).toContain(env.os);
|
|
20
|
+
});
|
|
21
|
+
it("claudeHomeExists is a boolean", async () => {
|
|
22
|
+
const env = await detect();
|
|
23
|
+
expect(typeof env.claudeHomeExists).toBe("boolean");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { writeFile, readFile, mkdir, mkdtemp, readdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { copyIfChanged, unlinkFiles, syncAssets } from "../lib/file-ops.js";
|
|
6
|
+
let tmpDir;
|
|
7
|
+
let srcDir;
|
|
8
|
+
let destDir;
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
tmpDir = await mkdtemp(join(tmpdir(), "uluops-fileops-"));
|
|
11
|
+
srcDir = join(tmpDir, "src");
|
|
12
|
+
destDir = join(tmpDir, "dest");
|
|
13
|
+
await mkdir(srcDir, { recursive: true });
|
|
14
|
+
await mkdir(destDir, { recursive: true });
|
|
15
|
+
});
|
|
16
|
+
describe("copyIfChanged", () => {
|
|
17
|
+
it("copies file when destination does not exist", async () => {
|
|
18
|
+
await writeFile(join(srcDir, "a.md"), "content A");
|
|
19
|
+
const result = await copyIfChanged(join(srcDir, "a.md"), join(destDir, "a.md"), false);
|
|
20
|
+
expect(result).toBe("copied");
|
|
21
|
+
expect(await readFile(join(destDir, "a.md"), "utf-8")).toBe("content A");
|
|
22
|
+
});
|
|
23
|
+
it("skips file when content is identical", async () => {
|
|
24
|
+
await writeFile(join(srcDir, "a.md"), "same");
|
|
25
|
+
await writeFile(join(destDir, "a.md"), "same");
|
|
26
|
+
const result = await copyIfChanged(join(srcDir, "a.md"), join(destDir, "a.md"), false);
|
|
27
|
+
expect(result).toBe("skipped");
|
|
28
|
+
});
|
|
29
|
+
it("copies file when content differs", async () => {
|
|
30
|
+
await writeFile(join(srcDir, "a.md"), "new content");
|
|
31
|
+
await writeFile(join(destDir, "a.md"), "old content");
|
|
32
|
+
const result = await copyIfChanged(join(srcDir, "a.md"), join(destDir, "a.md"), false);
|
|
33
|
+
expect(result).toBe("copied");
|
|
34
|
+
expect(await readFile(join(destDir, "a.md"), "utf-8")).toBe("new content");
|
|
35
|
+
});
|
|
36
|
+
it("does not write in dry-run mode", async () => {
|
|
37
|
+
await writeFile(join(srcDir, "a.md"), "content");
|
|
38
|
+
const result = await copyIfChanged(join(srcDir, "a.md"), join(destDir, "a.md"), true);
|
|
39
|
+
expect(result).toBe("copied");
|
|
40
|
+
// File should NOT exist
|
|
41
|
+
const files = await readdir(destDir);
|
|
42
|
+
expect(files).not.toContain("a.md");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe("unlinkFiles", () => {
|
|
46
|
+
it("removes listed files and returns count", async () => {
|
|
47
|
+
await writeFile(join(destDir, "a.md"), "a");
|
|
48
|
+
await writeFile(join(destDir, "b.md"), "b");
|
|
49
|
+
const removed = await unlinkFiles(destDir, ["a.md", "b.md"]);
|
|
50
|
+
expect(removed).toBe(2);
|
|
51
|
+
expect(await readdir(destDir)).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
it("returns 0 for already-missing files", async () => {
|
|
54
|
+
const removed = await unlinkFiles(destDir, ["nonexistent.md"]);
|
|
55
|
+
expect(removed).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
describe("syncAssets", () => {
|
|
59
|
+
it("copies all .md files from source to destination", async () => {
|
|
60
|
+
await writeFile(join(srcDir, "one.md"), "one");
|
|
61
|
+
await writeFile(join(srcDir, "two.md"), "two");
|
|
62
|
+
await writeFile(join(srcDir, "skip.txt"), "not md");
|
|
63
|
+
const result = await syncAssets({ srcDir, destDir, dryRun: false });
|
|
64
|
+
expect(result.copied).toBe(2);
|
|
65
|
+
expect(result.files).toContain("one.md");
|
|
66
|
+
expect(result.files).toContain("two.md");
|
|
67
|
+
expect(result.files).not.toContain("skip.txt");
|
|
68
|
+
});
|
|
69
|
+
it("skips unchanged files on second run", async () => {
|
|
70
|
+
await writeFile(join(srcDir, "one.md"), "content");
|
|
71
|
+
await syncAssets({ srcDir, destDir, dryRun: false });
|
|
72
|
+
const result = await syncAssets({ srcDir, destDir, dryRun: false });
|
|
73
|
+
expect(result.copied).toBe(0);
|
|
74
|
+
expect(result.skipped).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
it("removes old manifest files no longer in source", async () => {
|
|
77
|
+
await writeFile(join(srcDir, "keep.md"), "keep");
|
|
78
|
+
await writeFile(join(destDir, "removed.md"), "old");
|
|
79
|
+
const result = await syncAssets({
|
|
80
|
+
srcDir,
|
|
81
|
+
destDir,
|
|
82
|
+
dryRun: false,
|
|
83
|
+
oldManifestFiles: ["keep.md", "removed.md"],
|
|
84
|
+
});
|
|
85
|
+
expect(result.removed).toBe(1);
|
|
86
|
+
expect(await readdir(destDir)).toContain("keep.md");
|
|
87
|
+
expect(await readdir(destDir)).not.toContain("removed.md");
|
|
88
|
+
});
|
|
89
|
+
it("creates destination directory if missing", async () => {
|
|
90
|
+
const newDest = join(tmpDir, "newdir");
|
|
91
|
+
await writeFile(join(srcDir, "a.md"), "a");
|
|
92
|
+
const result = await syncAssets({
|
|
93
|
+
srcDir,
|
|
94
|
+
destDir: newDest,
|
|
95
|
+
dryRun: false,
|
|
96
|
+
});
|
|
97
|
+
expect(result.copied).toBe(1);
|
|
98
|
+
expect(await readdir(newDest)).toContain("a.md");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { fileHash } from "../lib/hash.js";
|
|
3
|
+
describe("fileHash", () => {
|
|
4
|
+
it("returns a 12-character hex string", () => {
|
|
5
|
+
const hash = fileHash("hello world");
|
|
6
|
+
expect(hash).toMatch(/^[0-9a-f]{12}$/);
|
|
7
|
+
});
|
|
8
|
+
it("returns same hash for same content", () => {
|
|
9
|
+
expect(fileHash("test")).toBe(fileHash("test"));
|
|
10
|
+
});
|
|
11
|
+
it("returns different hash for different content", () => {
|
|
12
|
+
expect(fileHash("a")).not.toBe(fileHash("b"));
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { writeFile, unlink, mkdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
// We need to control the manifest path, so we mock paths.js
|
|
6
|
+
const tmpDir = join(tmpdir(), "uluops-manifest-test-" + Date.now());
|
|
7
|
+
const manifestPath = join(tmpDir, "uluops-manifest.json");
|
|
8
|
+
vi.mock("../lib/paths.js", async (importOriginal) => {
|
|
9
|
+
const original = await importOriginal();
|
|
10
|
+
return {
|
|
11
|
+
...original,
|
|
12
|
+
getManifestPath: () => manifestPath,
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
// Import after mock is set up
|
|
16
|
+
const { loadManifest, saveManifest, deleteManifest } = await import("../lib/manifest.js");
|
|
17
|
+
const sampleManifest = {
|
|
18
|
+
version: "0.1.0",
|
|
19
|
+
installedAt: "2026-03-08T00:00:00.000Z",
|
|
20
|
+
mcpScope: "global",
|
|
21
|
+
mcpConfigPath: "/home/user/.claude.json",
|
|
22
|
+
defsScope: "global",
|
|
23
|
+
defsPath: "/home/user/.claude",
|
|
24
|
+
shellModified: false,
|
|
25
|
+
agents: ["code-validator-agent.md"],
|
|
26
|
+
commands: ["agents/validate.md"],
|
|
27
|
+
};
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
await mkdir(tmpDir, { recursive: true });
|
|
30
|
+
});
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
try {
|
|
33
|
+
await unlink(manifestPath);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// may not exist
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
describe("loadManifest", () => {
|
|
40
|
+
it("returns null when manifest does not exist", async () => {
|
|
41
|
+
const result = await loadManifest();
|
|
42
|
+
expect(result).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
it("returns parsed manifest when file exists", async () => {
|
|
45
|
+
await writeFile(manifestPath, JSON.stringify(sampleManifest, null, 2));
|
|
46
|
+
const result = await loadManifest();
|
|
47
|
+
expect(result).toEqual(sampleManifest);
|
|
48
|
+
});
|
|
49
|
+
it("returns null on malformed JSON", async () => {
|
|
50
|
+
await writeFile(manifestPath, "{ invalid json }");
|
|
51
|
+
const result = await loadManifest();
|
|
52
|
+
expect(result).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe("saveManifest", () => {
|
|
56
|
+
it("writes manifest as formatted JSON", async () => {
|
|
57
|
+
await saveManifest(sampleManifest);
|
|
58
|
+
const raw = await import("node:fs/promises").then((fs) => fs.readFile(manifestPath, "utf-8"));
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
expect(parsed).toEqual(sampleManifest);
|
|
61
|
+
});
|
|
62
|
+
it("round-trips correctly through save and load", async () => {
|
|
63
|
+
await saveManifest(sampleManifest);
|
|
64
|
+
const loaded = await loadManifest();
|
|
65
|
+
expect(loaded).toEqual(sampleManifest);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe("deleteManifest", () => {
|
|
69
|
+
it("deletes existing manifest", async () => {
|
|
70
|
+
await writeFile(manifestPath, JSON.stringify(sampleManifest));
|
|
71
|
+
await deleteManifest();
|
|
72
|
+
const result = await loadManifest();
|
|
73
|
+
expect(result).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
it("does not throw if manifest does not exist", async () => {
|
|
76
|
+
await expect(deleteManifest()).resolves.toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getClaudeHome, getClaudeJsonPath, getLocalMcpPath, getManifestPath, getAgentsDir, getCommandsDir, } from "../lib/paths.js";
|
|
5
|
+
describe("path resolution", () => {
|
|
6
|
+
it("getClaudeHome returns ~/.claude", () => {
|
|
7
|
+
expect(getClaudeHome()).toBe(join(homedir(), ".claude"));
|
|
8
|
+
});
|
|
9
|
+
it("getClaudeJsonPath returns ~/.claude.json", () => {
|
|
10
|
+
expect(getClaudeJsonPath()).toBe(join(homedir(), ".claude.json"));
|
|
11
|
+
});
|
|
12
|
+
it("getLocalMcpPath returns .mcp.json in cwd", () => {
|
|
13
|
+
expect(getLocalMcpPath()).toBe(join(process.cwd(), ".mcp.json"));
|
|
14
|
+
});
|
|
15
|
+
it("getManifestPath returns ~/.claude/uluops-manifest.json", () => {
|
|
16
|
+
expect(getManifestPath()).toBe(join(homedir(), ".claude", "uluops-manifest.json"));
|
|
17
|
+
});
|
|
18
|
+
it("getAgentsDir returns ~/.claude/agents when not local", () => {
|
|
19
|
+
expect(getAgentsDir(false)).toBe(join(homedir(), ".claude", "agents"));
|
|
20
|
+
});
|
|
21
|
+
it("getAgentsDir returns ./uluops/agents when local", () => {
|
|
22
|
+
expect(getAgentsDir(true)).toBe(join(process.cwd(), "uluops", "agents"));
|
|
23
|
+
});
|
|
24
|
+
it("getCommandsDir returns ~/.claude/commands when not local", () => {
|
|
25
|
+
expect(getCommandsDir(false)).toBe(join(homedir(), ".claude", "commands"));
|
|
26
|
+
});
|
|
27
|
+
it("getCommandsDir returns ./uluops/commands when local", () => {
|
|
28
|
+
expect(getCommandsDir(true)).toBe(join(process.cwd(), "uluops", "commands"));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mergeUluopsHook, removeUluopsHook, hasUluopsHook, } from "../lib/settings-merger.js";
|
|
3
|
+
describe("settings-merger", () => {
|
|
4
|
+
describe("mergeUluopsHook", () => {
|
|
5
|
+
it("should add hook to empty settings", () => {
|
|
6
|
+
const result = mergeUluopsHook({}, "node ~/.claude/tools/agent-metrics/dist/hook.js");
|
|
7
|
+
expect(result.hooks).toBeDefined();
|
|
8
|
+
expect(result.hooks["SubagentStop"]).toHaveLength(1);
|
|
9
|
+
expect(result.hooks["SubagentStop"][0].hooks[0].command).toContain("tools/agent-metrics");
|
|
10
|
+
expect(result.hooks["SubagentStop"][0].hooks[0].timeout).toBe(30);
|
|
11
|
+
});
|
|
12
|
+
it("should preserve existing permissions", () => {
|
|
13
|
+
const settings = {
|
|
14
|
+
permissions: { allow: ["Bash(curl:*)"] },
|
|
15
|
+
};
|
|
16
|
+
const result = mergeUluopsHook(settings, "node ~/.claude/tools/agent-metrics/dist/hook.js");
|
|
17
|
+
expect(result.permissions).toEqual({ allow: ["Bash(curl:*)"] });
|
|
18
|
+
expect(result.hooks["SubagentStop"]).toHaveLength(1);
|
|
19
|
+
});
|
|
20
|
+
it("should preserve non-UluOps hooks", () => {
|
|
21
|
+
const settings = {
|
|
22
|
+
hooks: {
|
|
23
|
+
SubagentStop: [
|
|
24
|
+
{
|
|
25
|
+
hooks: [
|
|
26
|
+
{ type: "command", command: "echo custom-hook" },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
const result = mergeUluopsHook(settings, "node ~/.claude/tools/agent-metrics/dist/hook.js");
|
|
33
|
+
// Should have both: custom + UluOps
|
|
34
|
+
expect(result.hooks["SubagentStop"]).toHaveLength(2);
|
|
35
|
+
expect(result.hooks["SubagentStop"][0].hooks[0].command).toBe("echo custom-hook");
|
|
36
|
+
expect(result.hooks["SubagentStop"][1].hooks[0].command).toContain("tools/agent-metrics");
|
|
37
|
+
});
|
|
38
|
+
it("should replace existing UluOps hook on re-run", () => {
|
|
39
|
+
const settings = {
|
|
40
|
+
hooks: {
|
|
41
|
+
SubagentStop: [
|
|
42
|
+
{
|
|
43
|
+
hooks: [
|
|
44
|
+
{
|
|
45
|
+
type: "command",
|
|
46
|
+
command: "node /old/path/tools/agent-metrics/dist/hook.js",
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const result = mergeUluopsHook(settings, "node ~/.claude/tools/agent-metrics/dist/hook.js");
|
|
54
|
+
// Should replace, not duplicate
|
|
55
|
+
expect(result.hooks["SubagentStop"]).toHaveLength(1);
|
|
56
|
+
expect(result.hooks["SubagentStop"][0].hooks[0].command).toContain("~/.claude/tools/agent-metrics");
|
|
57
|
+
});
|
|
58
|
+
it("should preserve other hook event types", () => {
|
|
59
|
+
const settings = {
|
|
60
|
+
hooks: {
|
|
61
|
+
PreToolUse: [{ hooks: [{ type: "command", command: "echo pre" }] }],
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
const result = mergeUluopsHook(settings, "node ~/.claude/tools/agent-metrics/dist/hook.js");
|
|
65
|
+
expect(result.hooks["PreToolUse"]).toHaveLength(1);
|
|
66
|
+
expect(result.hooks["SubagentStop"]).toHaveLength(1);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe("removeUluopsHook", () => {
|
|
70
|
+
it("should remove UluOps hook and preserve permissions", () => {
|
|
71
|
+
const settings = {
|
|
72
|
+
permissions: { allow: ["Bash(curl:*)"] },
|
|
73
|
+
hooks: {
|
|
74
|
+
SubagentStop: [
|
|
75
|
+
{
|
|
76
|
+
hooks: [
|
|
77
|
+
{
|
|
78
|
+
type: "command",
|
|
79
|
+
command: "node ~/.claude/tools/agent-metrics/dist/hook.js",
|
|
80
|
+
timeout: 30,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const result = removeUluopsHook(settings);
|
|
88
|
+
expect(result.permissions).toEqual({ allow: ["Bash(curl:*)"] });
|
|
89
|
+
expect(result.hooks).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
it("should preserve non-UluOps hooks", () => {
|
|
92
|
+
const settings = {
|
|
93
|
+
hooks: {
|
|
94
|
+
SubagentStop: [
|
|
95
|
+
{ hooks: [{ type: "command", command: "echo custom" }] },
|
|
96
|
+
{
|
|
97
|
+
hooks: [
|
|
98
|
+
{
|
|
99
|
+
type: "command",
|
|
100
|
+
command: "node ~/.claude/tools/agent-metrics/dist/hook.js",
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
const result = removeUluopsHook(settings);
|
|
108
|
+
expect(result.hooks["SubagentStop"]).toHaveLength(1);
|
|
109
|
+
expect(result.hooks["SubagentStop"][0].hooks[0].command).toBe("echo custom");
|
|
110
|
+
});
|
|
111
|
+
it("should handle settings with no hooks", () => {
|
|
112
|
+
const settings = { permissions: { allow: [] } };
|
|
113
|
+
const result = removeUluopsHook(settings);
|
|
114
|
+
expect(result).toEqual({ permissions: { allow: [] } });
|
|
115
|
+
});
|
|
116
|
+
it("should clean up empty hooks object", () => {
|
|
117
|
+
const settings = {
|
|
118
|
+
hooks: {
|
|
119
|
+
SubagentStop: [
|
|
120
|
+
{
|
|
121
|
+
hooks: [
|
|
122
|
+
{
|
|
123
|
+
type: "command",
|
|
124
|
+
command: "node /some/tools/agent-metrics/hook.js",
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
const result = removeUluopsHook(settings);
|
|
132
|
+
expect(result.hooks).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
describe("hasUluopsHook", () => {
|
|
136
|
+
it("should return true when UluOps hook is present", () => {
|
|
137
|
+
const settings = {
|
|
138
|
+
hooks: {
|
|
139
|
+
SubagentStop: [
|
|
140
|
+
{
|
|
141
|
+
hooks: [
|
|
142
|
+
{
|
|
143
|
+
type: "command",
|
|
144
|
+
command: "node ~/.claude/tools/agent-metrics/dist/hook.js",
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
expect(hasUluopsHook(settings)).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
it("should return false when no hooks exist", () => {
|
|
154
|
+
expect(hasUluopsHook({})).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
it("should return false when only non-UluOps hooks exist", () => {
|
|
157
|
+
const settings = {
|
|
158
|
+
hooks: {
|
|
159
|
+
SubagentStop: [
|
|
160
|
+
{ hooks: [{ type: "command", command: "echo custom" }] },
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
expect(hasUluopsHook(settings)).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { getShellProfile } from "../lib/paths.js";
|
|
3
|
+
afterEach(() => {
|
|
4
|
+
vi.unstubAllEnvs();
|
|
5
|
+
});
|
|
6
|
+
describe("getShellProfile", () => {
|
|
7
|
+
it("returns .zshrc for zsh shell", () => {
|
|
8
|
+
vi.stubEnv("SHELL", "/bin/zsh");
|
|
9
|
+
const result = getShellProfile();
|
|
10
|
+
expect(result).not.toBeNull();
|
|
11
|
+
expect(result.shell).toBe("zsh");
|
|
12
|
+
expect(result.path).toMatch(/\.zshrc$/);
|
|
13
|
+
});
|
|
14
|
+
it("returns .bashrc for bash on linux", () => {
|
|
15
|
+
vi.stubEnv("SHELL", "/bin/bash");
|
|
16
|
+
// getShellProfile checks platform() — on linux it returns .bashrc
|
|
17
|
+
const result = getShellProfile();
|
|
18
|
+
expect(result).not.toBeNull();
|
|
19
|
+
expect(result.shell).toBe("bash");
|
|
20
|
+
// On linux: .bashrc, on darwin: .bash_profile
|
|
21
|
+
expect(result.path).toMatch(/\.bash(rc|_profile)$/);
|
|
22
|
+
});
|
|
23
|
+
it("returns config.fish for fish shell", () => {
|
|
24
|
+
vi.stubEnv("SHELL", "/usr/bin/fish");
|
|
25
|
+
const result = getShellProfile();
|
|
26
|
+
expect(result).not.toBeNull();
|
|
27
|
+
expect(result.shell).toBe("fish");
|
|
28
|
+
expect(result.path).toMatch(/config\.fish$/);
|
|
29
|
+
});
|
|
30
|
+
it("returns null for unknown shell", () => {
|
|
31
|
+
vi.stubEnv("SHELL", "/bin/csh");
|
|
32
|
+
const result = getShellProfile();
|
|
33
|
+
expect(result).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
it("returns null when SHELL is empty", () => {
|
|
36
|
+
vi.stubEnv("SHELL", "");
|
|
37
|
+
const result = getShellProfile();
|
|
38
|
+
expect(result).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { writeFile, readFile, unlink, mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { writeShellExport, removeShellExport } from "../steps/shell.js";
|
|
6
|
+
let tmpDir;
|
|
7
|
+
let profilePath;
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
tmpDir = await mkdtemp(join(tmpdir(), "uluops-test-"));
|
|
10
|
+
profilePath = join(tmpDir, ".bashrc");
|
|
11
|
+
});
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
try {
|
|
14
|
+
await unlink(profilePath);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// may not exist
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
describe("writeShellExport", () => {
|
|
21
|
+
it("creates profile with fenced block if file does not exist", async () => {
|
|
22
|
+
await writeShellExport(profilePath, "ulr_abc123", false);
|
|
23
|
+
const content = await readFile(profilePath, "utf-8");
|
|
24
|
+
expect(content).toContain("# --- UluOps (managed by @uluops/setup) ---");
|
|
25
|
+
expect(content).toContain('export ULUOPS_API_KEY="ulr_abc123"');
|
|
26
|
+
expect(content).toContain("# --- /UluOps ---");
|
|
27
|
+
});
|
|
28
|
+
it("appends fenced block to existing file", async () => {
|
|
29
|
+
await writeFile(profilePath, "# existing content\n");
|
|
30
|
+
await writeShellExport(profilePath, "ulr_abc123", false);
|
|
31
|
+
const content = await readFile(profilePath, "utf-8");
|
|
32
|
+
expect(content).toContain("# existing content");
|
|
33
|
+
expect(content).toContain('export ULUOPS_API_KEY="ulr_abc123"');
|
|
34
|
+
});
|
|
35
|
+
it("replaces existing fenced block on re-run", async () => {
|
|
36
|
+
await writeShellExport(profilePath, "ulr_old", false);
|
|
37
|
+
await writeShellExport(profilePath, "ulr_new", false);
|
|
38
|
+
const content = await readFile(profilePath, "utf-8");
|
|
39
|
+
expect(content).toContain('export ULUOPS_API_KEY="ulr_new"');
|
|
40
|
+
expect(content).not.toContain("ulr_old");
|
|
41
|
+
// Only one fenced block should exist
|
|
42
|
+
expect(content.split("# --- UluOps").length).toBe(2);
|
|
43
|
+
});
|
|
44
|
+
it("does not modify files in dry-run mode", async () => {
|
|
45
|
+
await writeFile(profilePath, "# existing\n");
|
|
46
|
+
await writeShellExport(profilePath, "ulr_abc123", true);
|
|
47
|
+
const content = await readFile(profilePath, "utf-8");
|
|
48
|
+
expect(content).toBe("# existing\n");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe("removeShellExport", () => {
|
|
52
|
+
it("removes the fenced block from the file", async () => {
|
|
53
|
+
const initial = "# before\n\n# --- UluOps (managed by @uluops/setup) ---\nexport ULUOPS_API_KEY=\"ulr_abc\"\n# --- /UluOps ---\n\n# after\n";
|
|
54
|
+
await writeFile(profilePath, initial);
|
|
55
|
+
await removeShellExport(profilePath);
|
|
56
|
+
const content = await readFile(profilePath, "utf-8");
|
|
57
|
+
expect(content).toContain("# before");
|
|
58
|
+
expect(content).toContain("# after");
|
|
59
|
+
expect(content).not.toContain("ULUOPS_API_KEY");
|
|
60
|
+
expect(content).not.toContain("UluOps");
|
|
61
|
+
});
|
|
62
|
+
it("does nothing if no fenced block exists", async () => {
|
|
63
|
+
await writeFile(profilePath, "# no uluops here\n");
|
|
64
|
+
await removeShellExport(profilePath);
|
|
65
|
+
const content = await readFile(profilePath, "utf-8");
|
|
66
|
+
expect(content).toBe("# no uluops here\n");
|
|
67
|
+
});
|
|
68
|
+
it("does nothing if file does not exist", async () => {
|
|
69
|
+
await expect(removeShellExport(join(tmpDir, "nonexistent"))).resolves.toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|