@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.
Files changed (107) hide show
  1. package/README.md +178 -0
  2. package/assets/agents/api-contract-validator-agent.md +960 -0
  3. package/assets/agents/aristotle-analyst-agent.md +705 -0
  4. package/assets/agents/aristotle-explorer-agent.md +152 -0
  5. package/assets/agents/aristotle-forecaster-agent.md +666 -0
  6. package/assets/agents/aristotle-validator-agent.md +667 -0
  7. package/assets/agents/assumption-excavator-agent.md +1354 -0
  8. package/assets/agents/code-auditor-agent.md +1061 -0
  9. package/assets/agents/code-optimizer-agent.md +876 -0
  10. package/assets/agents/code-validator-agent.md +846 -0
  11. package/assets/agents/docs-validator-agent.md +490 -0
  12. package/assets/agents/frontend-validator-agent.md +844 -0
  13. package/assets/agents/mcp-validator-agent.md +827 -0
  14. package/assets/agents/pre-implementation-architect-agent.md +1036 -0
  15. package/assets/agents/prompt-engineer-agent.md +1158 -0
  16. package/assets/agents/prompt-pattern-analyzer-agent.md +907 -0
  17. package/assets/agents/prompt-quality-validator-agent.md +1018 -0
  18. package/assets/agents/public-interface-validator-agent.md +951 -0
  19. package/assets/agents/release-readiness-agent.md +482 -0
  20. package/assets/agents/security-analyst-agent.md +1093 -0
  21. package/assets/agents/test-architect-agent.md +861 -0
  22. package/assets/agents/type-safety-validator-agent.md +932 -0
  23. package/assets/agents/workflow-synthesis-agent.md +836 -0
  24. package/assets/commands/agents/api-contract.md +135 -0
  25. package/assets/commands/agents/architect.md +135 -0
  26. package/assets/commands/agents/aristotle-analyst.md +115 -0
  27. package/assets/commands/agents/aristotle-explorer.md +92 -0
  28. package/assets/commands/agents/aristotle-forecaster.md +114 -0
  29. package/assets/commands/agents/aristotle-validator.md +114 -0
  30. package/assets/commands/agents/assumption-excavator.md +114 -0
  31. package/assets/commands/agents/audit.md +136 -0
  32. package/assets/commands/agents/docs-validate.md +133 -0
  33. package/assets/commands/agents/frontend.md +135 -0
  34. package/assets/commands/agents/mcp-validate.md +136 -0
  35. package/assets/commands/agents/optimize.md +133 -0
  36. package/assets/commands/agents/pattern-analyzer.md +126 -0
  37. package/assets/commands/agents/prompt-quality.md +134 -0
  38. package/assets/commands/agents/prompt-validate.md +135 -0
  39. package/assets/commands/agents/public-interface.md +134 -0
  40. package/assets/commands/agents/release.md +135 -0
  41. package/assets/commands/agents/security.md +137 -0
  42. package/assets/commands/agents/test-review.md +136 -0
  43. package/assets/commands/agents/type-safety.md +135 -0
  44. package/assets/commands/agents/validate.md +134 -0
  45. package/assets/commands/agents/workflow-synthesis.md +101 -0
  46. package/assets/commands/workflows/aristotle.md +543 -0
  47. package/assets/commands/workflows/post-implementation.md +577 -0
  48. package/assets/commands/workflows/pre-implementation.md +670 -0
  49. package/assets/commands/workflows/prompt-audit.md +754 -0
  50. package/assets/commands/workflows/ship.md +721 -0
  51. package/dist/cli.d.ts +2 -0
  52. package/dist/cli.js +436 -0
  53. package/dist/lib/config-merger.d.ts +26 -0
  54. package/dist/lib/config-merger.js +63 -0
  55. package/dist/lib/file-ops.d.ts +23 -0
  56. package/dist/lib/file-ops.js +86 -0
  57. package/dist/lib/hash.d.ts +1 -0
  58. package/dist/lib/hash.js +4 -0
  59. package/dist/lib/manifest.d.ts +16 -0
  60. package/dist/lib/manifest.js +34 -0
  61. package/dist/lib/paths.d.ts +14 -0
  62. package/dist/lib/paths.js +49 -0
  63. package/dist/lib/settings-merger.d.ts +43 -0
  64. package/dist/lib/settings-merger.js +91 -0
  65. package/dist/steps/agents.d.ts +8 -0
  66. package/dist/steps/agents.js +14 -0
  67. package/dist/steps/auth.d.ts +12 -0
  68. package/dist/steps/auth.js +80 -0
  69. package/dist/steps/commands.d.ts +9 -0
  70. package/dist/steps/commands.js +69 -0
  71. package/dist/steps/detect.d.ts +9 -0
  72. package/dist/steps/detect.js +30 -0
  73. package/dist/steps/mcp.d.ts +6 -0
  74. package/dist/steps/mcp.js +40 -0
  75. package/dist/steps/metrics.d.ts +22 -0
  76. package/dist/steps/metrics.js +176 -0
  77. package/dist/steps/shell.d.ts +2 -0
  78. package/dist/steps/shell.js +48 -0
  79. package/dist/steps/signup.d.ts +13 -0
  80. package/dist/steps/signup.js +92 -0
  81. package/dist/steps/verify.d.ts +10 -0
  82. package/dist/steps/verify.js +184 -0
  83. package/dist/test/auth.test.d.ts +1 -0
  84. package/dist/test/auth.test.js +43 -0
  85. package/dist/test/config-io.test.d.ts +1 -0
  86. package/dist/test/config-io.test.js +56 -0
  87. package/dist/test/config-merger.test.d.ts +1 -0
  88. package/dist/test/config-merger.test.js +94 -0
  89. package/dist/test/detect.test.d.ts +1 -0
  90. package/dist/test/detect.test.js +25 -0
  91. package/dist/test/file-ops.test.d.ts +1 -0
  92. package/dist/test/file-ops.test.js +100 -0
  93. package/dist/test/hash.test.d.ts +1 -0
  94. package/dist/test/hash.test.js +14 -0
  95. package/dist/test/manifest.test.d.ts +1 -0
  96. package/dist/test/manifest.test.js +78 -0
  97. package/dist/test/paths.test.d.ts +1 -0
  98. package/dist/test/paths.test.js +30 -0
  99. package/dist/test/settings-merger.test.d.ts +1 -0
  100. package/dist/test/settings-merger.test.js +167 -0
  101. package/dist/test/shell-profile.test.d.ts +1 -0
  102. package/dist/test/shell-profile.test.js +40 -0
  103. package/dist/test/shell.test.d.ts +1 -0
  104. package/dist/test/shell.test.js +71 -0
  105. package/dist/test/signup.test.d.ts +1 -0
  106. package/dist/test/signup.test.js +83 -0
  107. package/package.json +36 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Metrics Step
3
+ *
4
+ * Installs agent-metrics tool files to ~/.claude/tools/agent-metrics/
5
+ * and configures the SubagentStop hook in settings.json for auto-capture.
6
+ */
7
+ import { mkdir, readdir, copyFile, rm, access } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ import { getClaudeHome } from "../lib/paths.js";
10
+ import { readSettings, writeSettings, mergeUluopsHook, removeUluopsHook, } from "../lib/settings-merger.js";
11
+ /** Where agent-metrics dist files are installed */
12
+ export function getMetricsToolDir() {
13
+ return join(getClaudeHome(), "tools", "agent-metrics");
14
+ }
15
+ /** Path to Claude Code's settings.json */
16
+ export function getSettingsPath() {
17
+ return join(getClaudeHome(), "settings.json");
18
+ }
19
+ /** The hook command that runs on SubagentStop */
20
+ function getHookCommand() {
21
+ const toolDir = getMetricsToolDir();
22
+ return `node ${join(toolDir, "dist", "hook.js")}`;
23
+ }
24
+ /**
25
+ * Find the agent-metrics package source directory.
26
+ * Looks for it as a sibling package in the monorepo or as an npm dependency.
27
+ */
28
+ async function findMetricsSource() {
29
+ // Try to find the package via Node.js module resolution
30
+ try {
31
+ const resolved = import.meta.resolve("@uluops/agent-metrics");
32
+ // resolved is like file:///path/to/dist/index.js — get the package root
33
+ const distDir = new URL(".", resolved).pathname;
34
+ const pkgRoot = join(distDir, "..");
35
+ return pkgRoot;
36
+ }
37
+ catch {
38
+ // Not installed as dependency
39
+ }
40
+ return null;
41
+ }
42
+ /**
43
+ * Copy agent-metrics dist files to the tool directory.
44
+ * Copies all .js files needed for the hook and CLI.
45
+ */
46
+ async function copyToolFiles(srcRoot, destRoot, dryRun) {
47
+ const srcDist = join(srcRoot, "dist");
48
+ const destDist = join(destRoot, "dist");
49
+ if (!dryRun) {
50
+ await mkdir(destDist, { recursive: true });
51
+ await mkdir(join(destDist, "commands"), { recursive: true });
52
+ await mkdir(join(destDist, "display"), { recursive: true });
53
+ }
54
+ let filesCopied = 0;
55
+ // Copy top-level dist files
56
+ const topFiles = await readdir(srcDist);
57
+ for (const file of topFiles) {
58
+ if (!file.endsWith(".js"))
59
+ continue;
60
+ if (file.includes(".test."))
61
+ continue;
62
+ if (file === "test-utils.js")
63
+ continue;
64
+ if (!dryRun) {
65
+ await copyFile(join(srcDist, file), join(destDist, file));
66
+ }
67
+ filesCopied++;
68
+ }
69
+ // Copy commands/ subdirectory
70
+ try {
71
+ const cmdFiles = await readdir(join(srcDist, "commands"));
72
+ for (const file of cmdFiles) {
73
+ if (!file.endsWith(".js"))
74
+ continue;
75
+ if (file.includes(".test."))
76
+ continue;
77
+ if (!dryRun) {
78
+ await copyFile(join(srcDist, "commands", file), join(destDist, "commands", file));
79
+ }
80
+ filesCopied++;
81
+ }
82
+ }
83
+ catch {
84
+ // commands/ doesn't exist — not critical
85
+ }
86
+ // Copy display/ subdirectory
87
+ try {
88
+ const dispFiles = await readdir(join(srcDist, "display"));
89
+ for (const file of dispFiles) {
90
+ if (!file.endsWith(".js"))
91
+ continue;
92
+ if (file.includes(".test."))
93
+ continue;
94
+ if (!dryRun) {
95
+ await copyFile(join(srcDist, "display", file), join(destDist, "display", file));
96
+ }
97
+ filesCopied++;
98
+ }
99
+ }
100
+ catch {
101
+ // display/ doesn't exist — not critical
102
+ }
103
+ // Copy package.json (needed for CLI bin resolution)
104
+ try {
105
+ if (!dryRun) {
106
+ await copyFile(join(srcRoot, "package.json"), join(destRoot, "package.json"));
107
+ }
108
+ filesCopied++;
109
+ }
110
+ catch {
111
+ // Not critical
112
+ }
113
+ return filesCopied;
114
+ }
115
+ /**
116
+ * Install agent-metrics: copy tool files and configure SubagentStop hook.
117
+ */
118
+ export async function installMetrics(dryRun) {
119
+ const toolDir = getMetricsToolDir();
120
+ const settingsPath = getSettingsPath();
121
+ // Find source package
122
+ const srcRoot = await findMetricsSource();
123
+ let toolFilesCopied = 0;
124
+ if (srcRoot) {
125
+ // Copy tool files
126
+ if (!dryRun) {
127
+ await mkdir(toolDir, { recursive: true });
128
+ }
129
+ toolFilesCopied = await copyToolFiles(srcRoot, toolDir, dryRun);
130
+ }
131
+ else {
132
+ // Check if already installed (from previous run or install.sh)
133
+ try {
134
+ await access(join(toolDir, "dist", "hook.js"));
135
+ }
136
+ catch {
137
+ // Not found anywhere — skip tool installation, just configure hook
138
+ // if files happen to exist
139
+ }
140
+ }
141
+ // Configure hook in settings.json
142
+ let hookConfigured = false;
143
+ if (!dryRun) {
144
+ const settings = await readSettings(settingsPath);
145
+ const hookCommand = getHookCommand();
146
+ const merged = mergeUluopsHook(settings, hookCommand);
147
+ await writeSettings(settingsPath, merged);
148
+ hookConfigured = true;
149
+ }
150
+ else {
151
+ hookConfigured = true;
152
+ }
153
+ return { toolFilesCopied, hookConfigured };
154
+ }
155
+ /**
156
+ * Uninstall agent-metrics: remove hook from settings and optionally remove tool files.
157
+ */
158
+ export async function uninstallMetrics(dryRun) {
159
+ const settingsPath = getSettingsPath();
160
+ const toolDir = getMetricsToolDir();
161
+ // Remove hook from settings.json
162
+ if (!dryRun) {
163
+ const settings = await readSettings(settingsPath);
164
+ const cleaned = removeUluopsHook(settings);
165
+ await writeSettings(settingsPath, cleaned);
166
+ }
167
+ // Remove tool directory
168
+ if (!dryRun) {
169
+ try {
170
+ await rm(toolDir, { recursive: true, force: true });
171
+ }
172
+ catch {
173
+ // Already gone
174
+ }
175
+ }
176
+ }
@@ -0,0 +1,2 @@
1
+ export declare function writeShellExport(profilePath: string, apiKey: string, dryRun: boolean): Promise<void>;
2
+ export declare function removeShellExport(profilePath: string): Promise<void>;
@@ -0,0 +1,48 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ const FENCE_START = "# --- UluOps (managed by @uluops/setup) ---";
3
+ const FENCE_END = "# --- /UluOps ---";
4
+ export async function writeShellExport(profilePath, apiKey, dryRun) {
5
+ const block = `${FENCE_START}\nexport ULUOPS_API_KEY="${apiKey}"\n${FENCE_END}`;
6
+ let content;
7
+ try {
8
+ content = await readFile(profilePath, "utf-8");
9
+ }
10
+ catch {
11
+ if (!dryRun) {
12
+ await writeFile(profilePath, block + "\n");
13
+ }
14
+ return;
15
+ }
16
+ const startIdx = content.indexOf(FENCE_START);
17
+ const endIdx = content.indexOf(FENCE_END);
18
+ if (startIdx !== -1 && endIdx !== -1) {
19
+ // Replace existing fenced block
20
+ const before = content.slice(0, startIdx);
21
+ const after = content.slice(endIdx + FENCE_END.length);
22
+ if (!dryRun) {
23
+ await writeFile(profilePath, before + block + after);
24
+ }
25
+ }
26
+ else {
27
+ // Append
28
+ if (!dryRun) {
29
+ await writeFile(profilePath, content.trimEnd() + "\n\n" + block + "\n");
30
+ }
31
+ }
32
+ }
33
+ export async function removeShellExport(profilePath) {
34
+ let content;
35
+ try {
36
+ content = await readFile(profilePath, "utf-8");
37
+ }
38
+ catch {
39
+ return;
40
+ }
41
+ const startIdx = content.indexOf(FENCE_START);
42
+ const endIdx = content.indexOf(FENCE_END);
43
+ if (startIdx !== -1 && endIdx !== -1) {
44
+ const before = content.slice(0, startIdx);
45
+ const after = content.slice(endIdx + FENCE_END.length);
46
+ await writeFile(profilePath, (before + after).replace(/\n{3,}/g, "\n\n"));
47
+ }
48
+ }
@@ -0,0 +1,13 @@
1
+ import type { AuthResult } from "./auth.js";
2
+ /**
3
+ * Password complexity rules (matches ops-uluops-api validation).
4
+ * Validated client-side for instant feedback before network round-trip.
5
+ */
6
+ declare function validatePassword(password: string): string | true;
7
+ declare function validateEmail(email: string): string | true;
8
+ /**
9
+ * Interactive signup flow: create account + generate API key.
10
+ * Returns the same AuthResult shape as resolveApiKey for seamless integration.
11
+ */
12
+ export declare function signup(): Promise<AuthResult>;
13
+ export { validatePassword, validateEmail };
@@ -0,0 +1,92 @@
1
+ const API_BASE = "https://api.uluops.ai/api/v1";
2
+ /**
3
+ * Password complexity rules (matches ops-uluops-api validation).
4
+ * Validated client-side for instant feedback before network round-trip.
5
+ */
6
+ function validatePassword(password) {
7
+ if (password.length < 8)
8
+ return "Password must be at least 8 characters";
9
+ if (password.length > 128)
10
+ return "Password must be at most 128 characters";
11
+ if (!/[a-z]/.test(password))
12
+ return "Password must include a lowercase letter";
13
+ if (!/[A-Z]/.test(password))
14
+ return "Password must include an uppercase letter";
15
+ if (!/[0-9]/.test(password))
16
+ return "Password must include a number";
17
+ return true;
18
+ }
19
+ function validateEmail(email) {
20
+ if (!email.trim())
21
+ return "Email is required";
22
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
23
+ return "Invalid email format";
24
+ return true;
25
+ }
26
+ /**
27
+ * Interactive signup flow: create account + generate API key.
28
+ * Returns the same AuthResult shape as resolveApiKey for seamless integration.
29
+ */
30
+ export async function signup() {
31
+ const { input, password } = await import("@inquirer/prompts");
32
+ const email = await input({
33
+ message: "Email",
34
+ validate: validateEmail,
35
+ });
36
+ const pwd = await password({
37
+ message: "Password",
38
+ mask: "*",
39
+ validate: validatePassword,
40
+ });
41
+ // Register
42
+ const registerRes = await callApi(`${API_BASE}/auth/register`, "POST", { email, password: pwd });
43
+ const sessionToken = registerRes.data.sessionToken;
44
+ // Create API key using the session
45
+ const keyRes = await callApi(`${API_BASE}/auth/keys`, "POST", { name: "Setup CLI" }, sessionToken);
46
+ return {
47
+ apiKey: keyRes.data.key,
48
+ email: registerRes.data.user.email,
49
+ };
50
+ }
51
+ async function callApi(url, method, body, bearerToken) {
52
+ const headers = {
53
+ "Content-Type": "application/json",
54
+ };
55
+ if (bearerToken) {
56
+ headers["Authorization"] = `Bearer ${bearerToken}`;
57
+ }
58
+ let res;
59
+ try {
60
+ res = await fetch(url, {
61
+ method,
62
+ headers,
63
+ body: JSON.stringify(body),
64
+ signal: AbortSignal.timeout(15000),
65
+ });
66
+ }
67
+ catch (err) {
68
+ if (err instanceof TypeError) {
69
+ throw new Error("Can't reach api.uluops.ai — check your connection.");
70
+ }
71
+ throw err;
72
+ }
73
+ if (res.ok) {
74
+ return (await res.json());
75
+ }
76
+ // Handle known error codes
77
+ const errorBody = await res.json().catch(() => null);
78
+ const message = errorBody?.error?.message ?? errorBody?.message ?? `HTTP ${res.status}`;
79
+ if (res.status === 409) {
80
+ throw new Error(`Email already registered. Run without --signup and use your existing API key.`);
81
+ }
82
+ if (res.status === 429) {
83
+ const retryAfter = res.headers.get("Retry-After");
84
+ throw new Error(`Rate limited — try again${retryAfter ? ` in ${retryAfter}s` : " shortly"}.`);
85
+ }
86
+ if (res.status === 400) {
87
+ throw new Error(`Registration failed: ${message}`);
88
+ }
89
+ throw new Error(`Signup failed (${res.status}): ${message}`);
90
+ }
91
+ // Exported for testing
92
+ export { validatePassword, validateEmail };
@@ -0,0 +1,10 @@
1
+ interface VerifyResult {
2
+ ok: boolean;
3
+ checks: {
4
+ label: string;
5
+ passed: boolean;
6
+ detail?: string;
7
+ }[];
8
+ }
9
+ export declare function verify(): Promise<VerifyResult>;
10
+ export {};
@@ -0,0 +1,184 @@
1
+ import { readdir, access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { loadManifest } from "../lib/manifest.js";
4
+ import { readConfig } from "../lib/config-merger.js";
5
+ import { readSettings, hasUluopsHook } from "../lib/settings-merger.js";
6
+ import { getMetricsToolDir, getSettingsPath } from "./metrics.js";
7
+ export async function verify() {
8
+ const checks = [];
9
+ let allOk = true;
10
+ // 1. Manifest
11
+ const manifest = await loadManifest();
12
+ if (!manifest) {
13
+ checks.push({
14
+ label: "Manifest found",
15
+ passed: false,
16
+ detail: "No manifest — run npx @uluops/setup first",
17
+ });
18
+ return { ok: false, checks };
19
+ }
20
+ checks.push({
21
+ label: `Manifest found (v${manifest.version}, installed ${manifest.installedAt.split("T")[0]})`,
22
+ passed: true,
23
+ });
24
+ // 2. MCP config
25
+ const config = await readConfig(manifest.mcpConfigPath);
26
+ const hasTracker = !!config.mcpServers?.["uluops-tracker"];
27
+ const hasRegistry = !!config.mcpServers?.["uluops-registry"];
28
+ if (hasTracker && hasRegistry) {
29
+ checks.push({
30
+ label: `MCP config present in ${manifest.mcpConfigPath} (2 servers)`,
31
+ passed: true,
32
+ });
33
+ }
34
+ else {
35
+ checks.push({
36
+ label: "MCP config",
37
+ passed: false,
38
+ detail: `Missing: ${[!hasTracker && "tracker", !hasRegistry && "registry"].filter(Boolean).join(", ")}`,
39
+ });
40
+ allOk = false;
41
+ }
42
+ // 3. Agent files
43
+ const agentsDir = join(manifest.defsPath, "agents");
44
+ try {
45
+ const agentFiles = await readdir(agentsDir);
46
+ const found = manifest.agents.filter((a) => agentFiles.includes(a)).length;
47
+ if (found === manifest.agents.length) {
48
+ checks.push({
49
+ label: `${found}/${manifest.agents.length} agents in ${agentsDir}`,
50
+ passed: true,
51
+ });
52
+ }
53
+ else {
54
+ checks.push({
55
+ label: `${found}/${manifest.agents.length} agents in ${agentsDir}`,
56
+ passed: false,
57
+ detail: `Missing ${manifest.agents.length - found} agent(s)`,
58
+ });
59
+ allOk = false;
60
+ }
61
+ }
62
+ catch {
63
+ checks.push({
64
+ label: "Agent files",
65
+ passed: false,
66
+ detail: `Directory not found: ${agentsDir}`,
67
+ });
68
+ allOk = false;
69
+ }
70
+ // 4. Command files
71
+ const commandsDir = join(manifest.defsPath, "commands");
72
+ try {
73
+ let found = 0;
74
+ for (const cmd of manifest.commands) {
75
+ try {
76
+ await access(join(commandsDir, cmd));
77
+ found++;
78
+ }
79
+ catch {
80
+ // Missing
81
+ }
82
+ }
83
+ if (found === manifest.commands.length) {
84
+ checks.push({
85
+ label: `${found}/${manifest.commands.length} commands in ${commandsDir}`,
86
+ passed: true,
87
+ });
88
+ }
89
+ else {
90
+ checks.push({
91
+ label: `${found}/${manifest.commands.length} commands in ${commandsDir}`,
92
+ passed: false,
93
+ detail: `Missing ${manifest.commands.length - found} command(s)`,
94
+ });
95
+ allOk = false;
96
+ }
97
+ }
98
+ catch {
99
+ checks.push({
100
+ label: "Command files",
101
+ passed: false,
102
+ detail: `Directory not found: ${commandsDir}`,
103
+ });
104
+ allOk = false;
105
+ }
106
+ // 5. Agent metrics hook
107
+ if (manifest.metricsHookInstalled) {
108
+ const settings = await readSettings(getSettingsPath());
109
+ const hookPresent = hasUluopsHook(settings);
110
+ const toolDir = getMetricsToolDir();
111
+ let hookFilePresent = false;
112
+ try {
113
+ await access(join(toolDir, "dist", "hook.js"));
114
+ hookFilePresent = true;
115
+ }
116
+ catch {
117
+ // Missing
118
+ }
119
+ if (hookPresent && hookFilePresent) {
120
+ checks.push({
121
+ label: "Agent metrics hook configured and tool files present",
122
+ passed: true,
123
+ });
124
+ }
125
+ else {
126
+ const missing = [
127
+ !hookPresent && "hook not in settings.json",
128
+ !hookFilePresent && "hook.js not found",
129
+ ]
130
+ .filter(Boolean)
131
+ .join(", ");
132
+ checks.push({
133
+ label: "Agent metrics",
134
+ passed: false,
135
+ detail: missing,
136
+ });
137
+ allOk = false;
138
+ }
139
+ }
140
+ // 6. API connectivity
141
+ const apiKey = extractApiKey(config);
142
+ if (apiKey) {
143
+ try {
144
+ const res = await fetch("https://api.uluops.ai/api/v1/registry/users/me", {
145
+ headers: { Authorization: `Bearer ${apiKey}` },
146
+ signal: AbortSignal.timeout(5000),
147
+ });
148
+ if (res.ok) {
149
+ const data = (await res.json());
150
+ checks.push({
151
+ label: `API key valid${data.email ? ` (user: ${data.email})` : ""}`,
152
+ passed: true,
153
+ });
154
+ }
155
+ else {
156
+ checks.push({
157
+ label: "API key valid",
158
+ passed: false,
159
+ detail: `Server returned ${res.status}`,
160
+ });
161
+ allOk = false;
162
+ }
163
+ }
164
+ catch {
165
+ checks.push({
166
+ label: "API connectivity",
167
+ passed: false,
168
+ detail: "Can't reach api.uluops.ai",
169
+ });
170
+ allOk = false;
171
+ }
172
+ }
173
+ return { ok: allOk, checks };
174
+ }
175
+ function extractApiKey(config) {
176
+ const raw = config.mcpServers;
177
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
178
+ return process.env["ULUOPS_API_KEY"];
179
+ }
180
+ const servers = raw;
181
+ return (servers["uluops-registry"]?.env?.["ULUOPS_API_KEY"] ??
182
+ servers["uluops-tracker"]?.env?.["ULUOPS_TRACKER_API_KEY"] ??
183
+ process.env["ULUOPS_API_KEY"]);
184
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { resolveApiKey } from "../steps/auth.js";
3
+ afterEach(() => {
4
+ vi.unstubAllEnvs();
5
+ });
6
+ describe("resolveApiKey", () => {
7
+ it("uses --api-key flag when provided", async () => {
8
+ const result = await resolveApiKey({
9
+ apiKeyFlag: "ulr_test123",
10
+ skipValidation: true,
11
+ });
12
+ expect(result.apiKey).toBe("ulr_test123");
13
+ expect(result.email).toBeNull();
14
+ });
15
+ it("uses ULUOPS_API_KEY env var when no flag", async () => {
16
+ vi.stubEnv("ULUOPS_API_KEY", "ulr_envkey");
17
+ const result = await resolveApiKey({
18
+ skipValidation: true,
19
+ });
20
+ expect(result.apiKey).toBe("ulr_envkey");
21
+ });
22
+ it("throws when key does not start with ulr_", async () => {
23
+ await expect(resolveApiKey({
24
+ apiKeyFlag: "bad_key",
25
+ skipValidation: true,
26
+ })).rejects.toThrow("API keys start with ulr_");
27
+ });
28
+ it("throws when no key found and not interactive", async () => {
29
+ vi.stubEnv("ULUOPS_API_KEY", "");
30
+ await expect(resolveApiKey({
31
+ interactive: false,
32
+ skipValidation: true,
33
+ })).rejects.toThrow("No API key found");
34
+ });
35
+ it("flag takes priority over env var", async () => {
36
+ vi.stubEnv("ULUOPS_API_KEY", "ulr_envkey");
37
+ const result = await resolveApiKey({
38
+ apiKeyFlag: "ulr_flagkey",
39
+ skipValidation: true,
40
+ });
41
+ expect(result.apiKey).toBe("ulr_flagkey");
42
+ });
43
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { writeFile, unlink, mkdtemp, readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { readConfig, writeConfig } from "../lib/config-merger.js";
6
+ let tmpDir;
7
+ beforeEach(async () => {
8
+ tmpDir = await mkdtemp(join(tmpdir(), "uluops-config-io-"));
9
+ });
10
+ afterEach(async () => {
11
+ try {
12
+ const { readdir } = await import("node:fs/promises");
13
+ for (const f of await readdir(tmpDir)) {
14
+ await unlink(join(tmpDir, f));
15
+ }
16
+ }
17
+ catch {
18
+ // cleanup best-effort
19
+ }
20
+ });
21
+ describe("readConfig", () => {
22
+ it("returns empty object when file does not exist", async () => {
23
+ const result = await readConfig(join(tmpDir, "nonexistent.json"));
24
+ expect(result).toEqual({});
25
+ });
26
+ it("returns empty object on malformed JSON", async () => {
27
+ const path = join(tmpDir, "bad.json");
28
+ await writeFile(path, "{ invalid }");
29
+ const result = await readConfig(path);
30
+ expect(result).toEqual({});
31
+ });
32
+ it("parses valid JSON correctly", async () => {
33
+ const path = join(tmpDir, "good.json");
34
+ await writeFile(path, JSON.stringify({ mcpServers: {}, numStartups: 5 }));
35
+ const result = await readConfig(path);
36
+ expect(result.mcpServers).toEqual({});
37
+ expect(result.numStartups).toBe(5);
38
+ });
39
+ });
40
+ describe("writeConfig", () => {
41
+ it("writes formatted JSON with trailing newline", async () => {
42
+ const path = join(tmpDir, "output.json");
43
+ await writeConfig(path, { mcpServers: {}, foo: "bar" });
44
+ const raw = await readFile(path, "utf-8");
45
+ expect(raw).toMatch(/\n$/);
46
+ const parsed = JSON.parse(raw);
47
+ expect(parsed.foo).toBe("bar");
48
+ });
49
+ it("round-trips through readConfig", async () => {
50
+ const path = join(tmpDir, "roundtrip.json");
51
+ const config = { mcpServers: { test: { command: "echo", args: [], env: {} } }, extra: true };
52
+ await writeConfig(path, config);
53
+ const loaded = await readConfig(path);
54
+ expect(loaded).toEqual(config);
55
+ });
56
+ });
@@ -0,0 +1 @@
1
+ export {};