@meshxdata/fops 0.0.1 → 0.0.4
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 +62 -40
- package/package.json +4 -3
- package/src/agent/agent.js +161 -68
- package/src/agent/agents.js +224 -0
- package/src/agent/context.js +287 -96
- package/src/agent/index.js +1 -0
- package/src/agent/llm.js +134 -20
- package/src/auth/coda.js +128 -0
- package/src/auth/index.js +1 -0
- package/src/auth/login.js +13 -13
- package/src/auth/oauth.js +4 -4
- package/src/commands/index.js +94 -21
- package/src/config.js +2 -2
- package/src/doctor.js +208 -22
- package/src/feature-flags.js +197 -0
- package/src/plugins/api.js +23 -0
- package/src/plugins/builtins/stack-api.js +36 -0
- package/src/plugins/index.js +1 -0
- package/src/plugins/knowledge.js +124 -0
- package/src/plugins/loader.js +67 -0
- package/src/plugins/registry.js +3 -0
- package/src/project.js +20 -1
- package/src/setup/aws.js +7 -7
- package/src/setup/setup.js +18 -12
- package/src/setup/wizard.js +86 -15
- package/src/shell.js +2 -2
- package/src/skills/foundation/SKILL.md +200 -66
- package/src/ui/confirm.js +3 -2
- package/src/ui/input.js +31 -34
- package/src/ui/spinner.js +39 -13
- package/src/ui/streaming.js +2 -2
- package/STRUCTURE.md +0 -43
- package/src/agent/agent.test.js +0 -233
- package/src/agent/context.test.js +0 -81
- package/src/agent/llm.test.js +0 -139
- package/src/auth/keychain.test.js +0 -185
- package/src/auth/login.test.js +0 -192
- package/src/auth/oauth.test.js +0 -118
- package/src/auth/resolve.test.js +0 -153
- package/src/config.test.js +0 -70
- package/src/doctor.test.js +0 -134
- package/src/plugins/api.test.js +0 -95
- package/src/plugins/discovery.test.js +0 -92
- package/src/plugins/hooks.test.js +0 -118
- package/src/plugins/manifest.test.js +0 -106
- package/src/plugins/registry.test.js +0 -43
- package/src/plugins/skills.test.js +0 -173
- package/src/project.test.js +0 -196
- package/src/setup/aws.test.js +0 -280
- package/src/shell.test.js +0 -72
- package/src/ui/banner.test.js +0 -97
- package/src/ui/spinner.test.js +0 -29
package/src/plugins/api.test.js
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { createPluginApi } from "./api.js";
|
|
3
|
-
import { createRegistry } from "./registry.js";
|
|
4
|
-
|
|
5
|
-
describe("plugins/api", () => {
|
|
6
|
-
describe("createPluginApi", () => {
|
|
7
|
-
it("returns api with id and methods", () => {
|
|
8
|
-
const registry = createRegistry();
|
|
9
|
-
const api = createPluginApi("test-plugin", registry);
|
|
10
|
-
expect(api.id).toBe("test-plugin");
|
|
11
|
-
expect(typeof api.registerCommand).toBe("function");
|
|
12
|
-
expect(typeof api.registerDoctorCheck).toBe("function");
|
|
13
|
-
expect(typeof api.registerHook).toBe("function");
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("has config property (empty by default)", () => {
|
|
17
|
-
const registry = createRegistry();
|
|
18
|
-
const api = createPluginApi("test-plugin", registry);
|
|
19
|
-
expect(api.config).toEqual({});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("registerCommand adds to registry.commands", () => {
|
|
23
|
-
const registry = createRegistry();
|
|
24
|
-
const api = createPluginApi("test-plugin", registry);
|
|
25
|
-
const spec = { name: "my-cmd", description: "A command" };
|
|
26
|
-
api.registerCommand(spec);
|
|
27
|
-
expect(registry.commands).toHaveLength(1);
|
|
28
|
-
expect(registry.commands[0]).toEqual({ pluginId: "test-plugin", spec });
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("registerCommand can add multiple commands", () => {
|
|
32
|
-
const registry = createRegistry();
|
|
33
|
-
const api = createPluginApi("test-plugin", registry);
|
|
34
|
-
api.registerCommand({ name: "cmd1" });
|
|
35
|
-
api.registerCommand({ name: "cmd2" });
|
|
36
|
-
api.registerCommand({ name: "cmd3" });
|
|
37
|
-
expect(registry.commands).toHaveLength(3);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("registerDoctorCheck adds to registry.doctorChecks", () => {
|
|
41
|
-
const registry = createRegistry();
|
|
42
|
-
const api = createPluginApi("test-plugin", registry);
|
|
43
|
-
const fn = vi.fn();
|
|
44
|
-
api.registerDoctorCheck({ name: "check-foo", fn });
|
|
45
|
-
expect(registry.doctorChecks).toHaveLength(1);
|
|
46
|
-
expect(registry.doctorChecks[0].name).toBe("check-foo");
|
|
47
|
-
expect(registry.doctorChecks[0].fn).toBe(fn);
|
|
48
|
-
expect(registry.doctorChecks[0].pluginId).toBe("test-plugin");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("registerHook adds to registry.hooks with default priority", () => {
|
|
52
|
-
const registry = createRegistry();
|
|
53
|
-
const api = createPluginApi("test-plugin", registry);
|
|
54
|
-
const handler = vi.fn();
|
|
55
|
-
api.registerHook("pre:up", handler);
|
|
56
|
-
expect(registry.hooks).toHaveLength(1);
|
|
57
|
-
expect(registry.hooks[0]).toEqual({
|
|
58
|
-
pluginId: "test-plugin",
|
|
59
|
-
event: "pre:up",
|
|
60
|
-
handler,
|
|
61
|
-
priority: 0,
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("registerHook respects custom priority", () => {
|
|
66
|
-
const registry = createRegistry();
|
|
67
|
-
const api = createPluginApi("test-plugin", registry);
|
|
68
|
-
const handler = vi.fn();
|
|
69
|
-
api.registerHook("post:up", handler, 10);
|
|
70
|
-
expect(registry.hooks[0].priority).toBe(10);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("multiple plugins can register on same registry", () => {
|
|
74
|
-
const registry = createRegistry();
|
|
75
|
-
const api1 = createPluginApi("plugin-a", registry);
|
|
76
|
-
const api2 = createPluginApi("plugin-b", registry);
|
|
77
|
-
api1.registerCommand({ name: "cmd-a" });
|
|
78
|
-
api2.registerCommand({ name: "cmd-b" });
|
|
79
|
-
api1.registerHook("pre:up", vi.fn());
|
|
80
|
-
api2.registerHook("pre:up", vi.fn());
|
|
81
|
-
expect(registry.commands).toHaveLength(2);
|
|
82
|
-
expect(registry.hooks).toHaveLength(2);
|
|
83
|
-
expect(registry.commands[0].pluginId).toBe("plugin-a");
|
|
84
|
-
expect(registry.commands[1].pluginId).toBe("plugin-b");
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("registerDoctorCheck preserves fn reference", () => {
|
|
88
|
-
const registry = createRegistry();
|
|
89
|
-
const api = createPluginApi("test", registry);
|
|
90
|
-
const checkFn = async (ok, warn, fail) => { ok("test passed"); };
|
|
91
|
-
api.registerDoctorCheck({ name: "my-check", fn: checkFn });
|
|
92
|
-
expect(registry.doctorChecks[0].fn).toBe(checkFn);
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
});
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
|
|
6
|
-
describe("plugins/discovery", () => {
|
|
7
|
-
let tmpHome;
|
|
8
|
-
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "fops-home-"));
|
|
11
|
-
vi.spyOn(os, "homedir").mockReturnValue(tmpHome);
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
afterEach(() => {
|
|
15
|
-
vi.restoreAllMocks();
|
|
16
|
-
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it("returns empty when no plugin dirs exist", async () => {
|
|
20
|
-
vi.resetModules();
|
|
21
|
-
const { discoverPlugins } = await import("./discovery.js");
|
|
22
|
-
const result = discoverPlugins();
|
|
23
|
-
const globalPlugins = result.filter((p) => p.source === "global");
|
|
24
|
-
expect(globalPlugins).toHaveLength(0);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("discovers global plugins with fops.plugin.json", async () => {
|
|
28
|
-
const pluginDir = path.join(tmpHome, ".fops", "plugins", "my-plugin");
|
|
29
|
-
fs.mkdirSync(pluginDir, { recursive: true });
|
|
30
|
-
fs.writeFileSync(
|
|
31
|
-
path.join(pluginDir, "fops.plugin.json"),
|
|
32
|
-
JSON.stringify({ id: "my-plugin", name: "My Plugin", version: "1.0.0" })
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
vi.resetModules();
|
|
36
|
-
const { discoverPlugins } = await import("./discovery.js");
|
|
37
|
-
const result = discoverPlugins();
|
|
38
|
-
const globalPlugins = result.filter((p) => p.source === "global");
|
|
39
|
-
expect(globalPlugins).toHaveLength(1);
|
|
40
|
-
expect(globalPlugins[0].id).toBe("my-plugin");
|
|
41
|
-
expect(globalPlugins[0].path).toBe(pluginDir);
|
|
42
|
-
expect(globalPlugins[0].source).toBe("global");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("skips global dirs without fops.plugin.json", async () => {
|
|
46
|
-
const pluginDir = path.join(tmpHome, ".fops", "plugins", "no-manifest");
|
|
47
|
-
fs.mkdirSync(pluginDir, { recursive: true });
|
|
48
|
-
fs.writeFileSync(path.join(pluginDir, "index.js"), "");
|
|
49
|
-
|
|
50
|
-
vi.resetModules();
|
|
51
|
-
const { discoverPlugins } = await import("./discovery.js");
|
|
52
|
-
const result = discoverPlugins();
|
|
53
|
-
const globalPlugins = result.filter((p) => p.source === "global");
|
|
54
|
-
expect(globalPlugins).toHaveLength(0);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("discovers multiple global plugins", async () => {
|
|
58
|
-
for (const name of ["plugin-a", "plugin-b", "plugin-c"]) {
|
|
59
|
-
const dir = path.join(tmpHome, ".fops", "plugins", name);
|
|
60
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
61
|
-
fs.writeFileSync(
|
|
62
|
-
path.join(dir, "fops.plugin.json"),
|
|
63
|
-
JSON.stringify({ id: name, name, version: "1.0.0" })
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
vi.resetModules();
|
|
68
|
-
const { discoverPlugins } = await import("./discovery.js");
|
|
69
|
-
const result = discoverPlugins();
|
|
70
|
-
const globalPlugins = result.filter((p) => p.source === "global");
|
|
71
|
-
expect(globalPlugins).toHaveLength(3);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("skips files (non-directories) in plugins dir", async () => {
|
|
75
|
-
const pluginsDir = path.join(tmpHome, ".fops", "plugins");
|
|
76
|
-
fs.mkdirSync(pluginsDir, { recursive: true });
|
|
77
|
-
fs.writeFileSync(path.join(pluginsDir, "not-a-dir.txt"), "");
|
|
78
|
-
|
|
79
|
-
vi.resetModules();
|
|
80
|
-
const { discoverPlugins } = await import("./discovery.js");
|
|
81
|
-
const result = discoverPlugins();
|
|
82
|
-
const globalPlugins = result.filter((p) => p.source === "global");
|
|
83
|
-
expect(globalPlugins).toHaveLength(0);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("returns array even when ~/.fops/plugins does not exist", async () => {
|
|
87
|
-
vi.resetModules();
|
|
88
|
-
const { discoverPlugins } = await import("./discovery.js");
|
|
89
|
-
const result = discoverPlugins();
|
|
90
|
-
expect(Array.isArray(result)).toBe(true);
|
|
91
|
-
});
|
|
92
|
-
});
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { runHook } from "./hooks.js";
|
|
3
|
-
|
|
4
|
-
describe("plugins/hooks", () => {
|
|
5
|
-
describe("runHook", () => {
|
|
6
|
-
it("runs matching handlers", async () => {
|
|
7
|
-
const handler = vi.fn();
|
|
8
|
-
const registry = {
|
|
9
|
-
hooks: [{ event: "pre:up", handler, priority: 0 }],
|
|
10
|
-
};
|
|
11
|
-
await runHook(registry, "pre:up", { root: "/project" });
|
|
12
|
-
expect(handler).toHaveBeenCalledWith({ root: "/project" });
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("skips non-matching events", async () => {
|
|
16
|
-
const handler = vi.fn();
|
|
17
|
-
const registry = {
|
|
18
|
-
hooks: [{ event: "pre:up", handler, priority: 0 }],
|
|
19
|
-
};
|
|
20
|
-
await runHook(registry, "post:up");
|
|
21
|
-
expect(handler).not.toHaveBeenCalled();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("sorts by priority (higher first)", async () => {
|
|
25
|
-
const order = [];
|
|
26
|
-
const low = vi.fn(() => order.push("low"));
|
|
27
|
-
const high = vi.fn(() => order.push("high"));
|
|
28
|
-
const registry = {
|
|
29
|
-
hooks: [
|
|
30
|
-
{ event: "pre:up", handler: low, priority: 1 },
|
|
31
|
-
{ event: "pre:up", handler: high, priority: 10 },
|
|
32
|
-
],
|
|
33
|
-
};
|
|
34
|
-
await runHook(registry, "pre:up");
|
|
35
|
-
expect(order).toEqual(["high", "low"]);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("runs handlers sequentially (not parallel)", async () => {
|
|
39
|
-
const order = [];
|
|
40
|
-
const slow = vi.fn(async () => {
|
|
41
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
42
|
-
order.push("slow");
|
|
43
|
-
});
|
|
44
|
-
const fast = vi.fn(() => order.push("fast"));
|
|
45
|
-
const registry = {
|
|
46
|
-
hooks: [
|
|
47
|
-
{ event: "test", handler: slow, priority: 10 },
|
|
48
|
-
{ event: "test", handler: fast, priority: 0 },
|
|
49
|
-
],
|
|
50
|
-
};
|
|
51
|
-
await runHook(registry, "test");
|
|
52
|
-
expect(order).toEqual(["slow", "fast"]);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("handles empty hooks array", async () => {
|
|
56
|
-
const registry = { hooks: [] };
|
|
57
|
-
await expect(runHook(registry, "pre:up")).resolves.toBeUndefined();
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("passes context to all handlers", async () => {
|
|
61
|
-
const h1 = vi.fn();
|
|
62
|
-
const h2 = vi.fn();
|
|
63
|
-
const ctx = { root: "/proj", extra: "data" };
|
|
64
|
-
const registry = {
|
|
65
|
-
hooks: [
|
|
66
|
-
{ event: "test", handler: h1, priority: 0 },
|
|
67
|
-
{ event: "test", handler: h2, priority: 0 },
|
|
68
|
-
],
|
|
69
|
-
};
|
|
70
|
-
await runHook(registry, "test", ctx);
|
|
71
|
-
expect(h1).toHaveBeenCalledWith(ctx);
|
|
72
|
-
expect(h2).toHaveBeenCalledWith(ctx);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("uses empty object as default context", async () => {
|
|
76
|
-
const handler = vi.fn();
|
|
77
|
-
const registry = {
|
|
78
|
-
hooks: [{ event: "test", handler, priority: 0 }],
|
|
79
|
-
};
|
|
80
|
-
await runHook(registry, "test");
|
|
81
|
-
expect(handler).toHaveBeenCalledWith({});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("handles three priority levels correctly", async () => {
|
|
85
|
-
const order = [];
|
|
86
|
-
const registry = {
|
|
87
|
-
hooks: [
|
|
88
|
-
{ event: "e", handler: () => order.push("med"), priority: 5 },
|
|
89
|
-
{ event: "e", handler: () => order.push("low"), priority: 1 },
|
|
90
|
-
{ event: "e", handler: () => order.push("high"), priority: 10 },
|
|
91
|
-
],
|
|
92
|
-
};
|
|
93
|
-
await runHook(registry, "e");
|
|
94
|
-
expect(order).toEqual(["high", "med", "low"]);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("only runs handlers for the exact event name", async () => {
|
|
98
|
-
const h1 = vi.fn();
|
|
99
|
-
const h2 = vi.fn();
|
|
100
|
-
const registry = {
|
|
101
|
-
hooks: [
|
|
102
|
-
{ event: "pre:up", handler: h1, priority: 0 },
|
|
103
|
-
{ event: "pre:down", handler: h2, priority: 0 },
|
|
104
|
-
],
|
|
105
|
-
};
|
|
106
|
-
await runHook(registry, "pre:up");
|
|
107
|
-
expect(h1).toHaveBeenCalled();
|
|
108
|
-
expect(h2).not.toHaveBeenCalled();
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("handler errors propagate", async () => {
|
|
112
|
-
const registry = {
|
|
113
|
-
hooks: [{ event: "test", handler: () => { throw new Error("boom"); }, priority: 0 }],
|
|
114
|
-
};
|
|
115
|
-
await expect(runHook(registry, "test")).rejects.toThrow("boom");
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
});
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { validateManifest } from "./manifest.js";
|
|
6
|
-
|
|
7
|
-
describe("plugins/manifest", () => {
|
|
8
|
-
let tmpDir;
|
|
9
|
-
|
|
10
|
-
beforeEach(() => {
|
|
11
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fops-manifest-"));
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
afterEach(() => {
|
|
15
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("returns null when no fops.plugin.json exists", () => {
|
|
19
|
-
expect(validateManifest(tmpDir)).toBe(null);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("returns null for invalid JSON", () => {
|
|
23
|
-
fs.writeFileSync(path.join(tmpDir, "fops.plugin.json"), "not json{{{");
|
|
24
|
-
expect(validateManifest(tmpDir)).toBe(null);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("returns null when id is missing", () => {
|
|
28
|
-
fs.writeFileSync(
|
|
29
|
-
path.join(tmpDir, "fops.plugin.json"),
|
|
30
|
-
JSON.stringify({ name: "Test", version: "1.0.0" })
|
|
31
|
-
);
|
|
32
|
-
expect(validateManifest(tmpDir)).toBe(null);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("returns null when name is missing", () => {
|
|
36
|
-
fs.writeFileSync(
|
|
37
|
-
path.join(tmpDir, "fops.plugin.json"),
|
|
38
|
-
JSON.stringify({ id: "test", version: "1.0.0" })
|
|
39
|
-
);
|
|
40
|
-
expect(validateManifest(tmpDir)).toBe(null);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("returns null when version is missing", () => {
|
|
44
|
-
fs.writeFileSync(
|
|
45
|
-
path.join(tmpDir, "fops.plugin.json"),
|
|
46
|
-
JSON.stringify({ id: "test", name: "Test" })
|
|
47
|
-
);
|
|
48
|
-
expect(validateManifest(tmpDir)).toBe(null);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("returns null when fields are not strings", () => {
|
|
52
|
-
fs.writeFileSync(
|
|
53
|
-
path.join(tmpDir, "fops.plugin.json"),
|
|
54
|
-
JSON.stringify({ id: "test", name: "Test", version: 1 })
|
|
55
|
-
);
|
|
56
|
-
expect(validateManifest(tmpDir)).toBe(null);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("returns null when id is empty string", () => {
|
|
60
|
-
fs.writeFileSync(
|
|
61
|
-
path.join(tmpDir, "fops.plugin.json"),
|
|
62
|
-
JSON.stringify({ id: "", name: "Test", version: "1.0.0" })
|
|
63
|
-
);
|
|
64
|
-
expect(validateManifest(tmpDir)).toBe(null);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("returns null when fields are numbers", () => {
|
|
68
|
-
fs.writeFileSync(
|
|
69
|
-
path.join(tmpDir, "fops.plugin.json"),
|
|
70
|
-
JSON.stringify({ id: 1, name: 2, version: 3 })
|
|
71
|
-
);
|
|
72
|
-
expect(validateManifest(tmpDir)).toBe(null);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("returns manifest when all required fields present", () => {
|
|
76
|
-
const manifest = { id: "test-plugin", name: "Test Plugin", version: "1.0.0" };
|
|
77
|
-
fs.writeFileSync(path.join(tmpDir, "fops.plugin.json"), JSON.stringify(manifest));
|
|
78
|
-
const result = validateManifest(tmpDir);
|
|
79
|
-
expect(result).toEqual(manifest);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("preserves extra fields in manifest", () => {
|
|
83
|
-
const manifest = {
|
|
84
|
-
id: "test-plugin",
|
|
85
|
-
name: "Test Plugin",
|
|
86
|
-
version: "1.0.0",
|
|
87
|
-
description: "A test",
|
|
88
|
-
author: "test",
|
|
89
|
-
skills: ["skills/foo"],
|
|
90
|
-
};
|
|
91
|
-
fs.writeFileSync(path.join(tmpDir, "fops.plugin.json"), JSON.stringify(manifest));
|
|
92
|
-
const result = validateManifest(tmpDir);
|
|
93
|
-
expect(result.description).toBe("A test");
|
|
94
|
-
expect(result.skills).toEqual(["skills/foo"]);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("returns null for empty JSON object", () => {
|
|
98
|
-
fs.writeFileSync(path.join(tmpDir, "fops.plugin.json"), "{}");
|
|
99
|
-
expect(validateManifest(tmpDir)).toBe(null);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("returns null for empty file", () => {
|
|
103
|
-
fs.writeFileSync(path.join(tmpDir, "fops.plugin.json"), "");
|
|
104
|
-
expect(validateManifest(tmpDir)).toBe(null);
|
|
105
|
-
});
|
|
106
|
-
});
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { createRegistry } from "./registry.js";
|
|
3
|
-
|
|
4
|
-
describe("plugins/registry", () => {
|
|
5
|
-
describe("createRegistry", () => {
|
|
6
|
-
it("returns object with expected arrays", () => {
|
|
7
|
-
const reg = createRegistry();
|
|
8
|
-
expect(reg).toEqual({
|
|
9
|
-
plugins: [],
|
|
10
|
-
commands: [],
|
|
11
|
-
doctorChecks: [],
|
|
12
|
-
hooks: [],
|
|
13
|
-
skills: [],
|
|
14
|
-
});
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("creates independent instances", () => {
|
|
18
|
-
const a = createRegistry();
|
|
19
|
-
const b = createRegistry();
|
|
20
|
-
a.plugins.push({ id: "test" });
|
|
21
|
-
expect(b.plugins).toHaveLength(0);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("all arrays are mutable", () => {
|
|
25
|
-
const reg = createRegistry();
|
|
26
|
-
reg.plugins.push({ id: "p1" });
|
|
27
|
-
reg.commands.push({ pluginId: "p1", spec: {} });
|
|
28
|
-
reg.doctorChecks.push({ pluginId: "p1", name: "check" });
|
|
29
|
-
reg.hooks.push({ pluginId: "p1", event: "test" });
|
|
30
|
-
reg.skills.push({ pluginId: "p1", name: "skill" });
|
|
31
|
-
expect(reg.plugins).toHaveLength(1);
|
|
32
|
-
expect(reg.commands).toHaveLength(1);
|
|
33
|
-
expect(reg.doctorChecks).toHaveLength(1);
|
|
34
|
-
expect(reg.hooks).toHaveLength(1);
|
|
35
|
-
expect(reg.skills).toHaveLength(1);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("has exactly 5 keys", () => {
|
|
39
|
-
const reg = createRegistry();
|
|
40
|
-
expect(Object.keys(reg)).toHaveLength(5);
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
});
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
|
|
6
|
-
vi.mock("execa", () => ({
|
|
7
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
8
|
-
}));
|
|
9
|
-
|
|
10
|
-
describe("plugins/skills", () => {
|
|
11
|
-
let tmpHome;
|
|
12
|
-
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "fops-skills-"));
|
|
15
|
-
vi.spyOn(os, "homedir").mockReturnValue(tmpHome);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
afterEach(() => {
|
|
19
|
-
vi.restoreAllMocks();
|
|
20
|
-
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("loads skills from ~/.fops/skills/", async () => {
|
|
24
|
-
const skillDir = path.join(tmpHome, ".fops", "skills", "my-skill");
|
|
25
|
-
fs.mkdirSync(skillDir, { recursive: true });
|
|
26
|
-
fs.writeFileSync(
|
|
27
|
-
path.join(skillDir, "SKILL.md"),
|
|
28
|
-
`---\nname: My Skill\ndescription: A test skill\n---\nThis is the skill content.`
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
vi.resetModules();
|
|
32
|
-
vi.mock("execa", () => ({
|
|
33
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
34
|
-
}));
|
|
35
|
-
const { loadSkills } = await import("./skills.js");
|
|
36
|
-
const skills = await loadSkills();
|
|
37
|
-
const userSkills = skills.filter((s) => s.name === "My Skill");
|
|
38
|
-
expect(userSkills).toHaveLength(1);
|
|
39
|
-
expect(userSkills[0].description).toBe("A test skill");
|
|
40
|
-
expect(userSkills[0].content).toBe("This is the skill content.");
|
|
41
|
-
expect(userSkills[0].pluginId).toBe(null);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("parses frontmatter correctly", async () => {
|
|
45
|
-
const skillDir = path.join(tmpHome, ".fops", "skills", "fm-test");
|
|
46
|
-
fs.mkdirSync(skillDir, { recursive: true });
|
|
47
|
-
fs.writeFileSync(
|
|
48
|
-
path.join(skillDir, "SKILL.md"),
|
|
49
|
-
`---\nname: FM Test\ndescription: Frontmatter test\n---\nBody text here.`
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
vi.resetModules();
|
|
53
|
-
vi.mock("execa", () => ({
|
|
54
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
55
|
-
}));
|
|
56
|
-
const { loadSkills } = await import("./skills.js");
|
|
57
|
-
const skills = await loadSkills();
|
|
58
|
-
const skill = skills.find((s) => s.name === "FM Test");
|
|
59
|
-
expect(skill).toBeDefined();
|
|
60
|
-
expect(skill.content).toBe("Body text here.");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("uses dir name as fallback name", async () => {
|
|
64
|
-
const skillDir = path.join(tmpHome, ".fops", "skills", "unnamed-skill");
|
|
65
|
-
fs.mkdirSync(skillDir, { recursive: true });
|
|
66
|
-
fs.writeFileSync(path.join(skillDir, "SKILL.md"), "Just a body, no frontmatter.");
|
|
67
|
-
|
|
68
|
-
vi.resetModules();
|
|
69
|
-
vi.mock("execa", () => ({
|
|
70
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
71
|
-
}));
|
|
72
|
-
const { loadSkills } = await import("./skills.js");
|
|
73
|
-
const skills = await loadSkills();
|
|
74
|
-
const skill = skills.find((s) => s.name === "unnamed-skill");
|
|
75
|
-
expect(skill).toBeDefined();
|
|
76
|
-
expect(skill.content).toBe("Just a body, no frontmatter.");
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("includes skills from registry", async () => {
|
|
80
|
-
vi.resetModules();
|
|
81
|
-
vi.mock("execa", () => ({
|
|
82
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
83
|
-
}));
|
|
84
|
-
const { loadSkills } = await import("./skills.js");
|
|
85
|
-
const registry = {
|
|
86
|
-
skills: [{ pluginId: "p1", name: "Plugin Skill", description: "From plugin", content: "plugin content" }],
|
|
87
|
-
};
|
|
88
|
-
const skills = await loadSkills(registry);
|
|
89
|
-
const pluginSkill = skills.find((s) => s.name === "Plugin Skill");
|
|
90
|
-
expect(pluginSkill).toBeDefined();
|
|
91
|
-
expect(pluginSkill.content).toBe("plugin content");
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("returns empty when no skills directories exist", async () => {
|
|
95
|
-
vi.resetModules();
|
|
96
|
-
vi.mock("execa", () => ({
|
|
97
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
98
|
-
}));
|
|
99
|
-
const { loadSkills } = await import("./skills.js");
|
|
100
|
-
const skills = await loadSkills();
|
|
101
|
-
// May include built-in skills from src/skills, but no user skills
|
|
102
|
-
const userSkills = skills.filter((s) => s.pluginId === null);
|
|
103
|
-
// All returned skills should have correct shape
|
|
104
|
-
for (const s of skills) {
|
|
105
|
-
expect(typeof s.name).toBe("string");
|
|
106
|
-
expect(typeof s.content).toBe("string");
|
|
107
|
-
}
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("returns empty when null registry is passed", async () => {
|
|
111
|
-
vi.resetModules();
|
|
112
|
-
vi.mock("execa", () => ({
|
|
113
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
114
|
-
}));
|
|
115
|
-
const { loadSkills } = await import("./skills.js");
|
|
116
|
-
const skills = await loadSkills(null);
|
|
117
|
-
expect(Array.isArray(skills)).toBe(true);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("skips dirs without SKILL.md", async () => {
|
|
121
|
-
const skillDir = path.join(tmpHome, ".fops", "skills", "no-skill-md");
|
|
122
|
-
fs.mkdirSync(skillDir, { recursive: true });
|
|
123
|
-
fs.writeFileSync(path.join(skillDir, "README.md"), "Not a skill");
|
|
124
|
-
|
|
125
|
-
vi.resetModules();
|
|
126
|
-
vi.mock("execa", () => ({
|
|
127
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
128
|
-
}));
|
|
129
|
-
const { loadSkills } = await import("./skills.js");
|
|
130
|
-
const skills = await loadSkills();
|
|
131
|
-
const match = skills.find((s) => s.name === "no-skill-md");
|
|
132
|
-
expect(match).toBeUndefined();
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("loads multiple skills from ~/.fops/skills/", async () => {
|
|
136
|
-
for (const name of ["skill-a", "skill-b"]) {
|
|
137
|
-
const dir = path.join(tmpHome, ".fops", "skills", name);
|
|
138
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
139
|
-
fs.writeFileSync(
|
|
140
|
-
path.join(dir, "SKILL.md"),
|
|
141
|
-
`---\nname: ${name}\ndescription: desc\n---\nContent for ${name}.`
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
vi.resetModules();
|
|
146
|
-
vi.mock("execa", () => ({
|
|
147
|
-
execa: vi.fn(() => Promise.resolve({ stdout: "" })),
|
|
148
|
-
}));
|
|
149
|
-
const { loadSkills } = await import("./skills.js");
|
|
150
|
-
const skills = await loadSkills();
|
|
151
|
-
expect(skills.find((s) => s.name === "skill-a")).toBeDefined();
|
|
152
|
-
expect(skills.find((s) => s.name === "skill-b")).toBeDefined();
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("handles multi-line frontmatter values", async () => {
|
|
156
|
-
const skillDir = path.join(tmpHome, ".fops", "skills", "multi-fm");
|
|
157
|
-
fs.mkdirSync(skillDir, { recursive: true });
|
|
158
|
-
fs.writeFileSync(
|
|
159
|
-
path.join(skillDir, "SKILL.md"),
|
|
160
|
-
`---\nname: Multi\ndescription: Has multiple fields\nrequires: nonexistent-binary\n---\nBody.`
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
vi.resetModules();
|
|
164
|
-
vi.mock("execa", () => ({
|
|
165
|
-
execa: vi.fn(() => { throw new Error("not found"); }),
|
|
166
|
-
}));
|
|
167
|
-
const { loadSkills } = await import("./skills.js");
|
|
168
|
-
const skills = await loadSkills();
|
|
169
|
-
// Should be filtered out due to requires gate
|
|
170
|
-
const match = skills.find((s) => s.name === "Multi");
|
|
171
|
-
expect(match).toBeUndefined();
|
|
172
|
-
});
|
|
173
|
-
});
|