@uluops/setup 0.2.0 → 0.4.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 +56 -53
  2. package/assets/agents/anxiety-reader-agent.md +464 -0
  3. package/assets/commands/agents/anxiety-reader.md +160 -0
  4. package/assets/commands/agents/api-contract.md +1 -0
  5. package/assets/commands/agents/architect.md +1 -0
  6. package/assets/commands/agents/aristotle-analyst.md +1 -0
  7. package/assets/commands/agents/aristotle-explorer.md +1 -0
  8. package/assets/commands/agents/aristotle-forecaster.md +1 -0
  9. package/assets/commands/agents/aristotle-validator.md +1 -0
  10. package/assets/commands/agents/assumption-excavator.md +1 -0
  11. package/assets/commands/agents/audit.md +1 -0
  12. package/assets/commands/agents/{validate.md → code-validate.md} +6 -5
  13. package/assets/commands/agents/docs-validate.md +1 -0
  14. package/assets/commands/agents/frontend.md +1 -0
  15. package/assets/commands/agents/mcp-validate.md +1 -0
  16. package/assets/commands/agents/optimize.md +1 -0
  17. package/assets/commands/agents/pattern-analyzer.md +1 -0
  18. package/assets/commands/agents/prompt-quality.md +1 -0
  19. package/assets/commands/agents/prompt-validate.md +1 -0
  20. package/assets/commands/agents/public-interface.md +1 -0
  21. package/assets/commands/agents/release.md +1 -0
  22. package/assets/commands/agents/security.md +1 -0
  23. package/assets/commands/agents/test-review.md +1 -0
  24. package/assets/commands/agents/type-safety.md +1 -0
  25. package/assets/commands/agents/workflow-synthesis.md +1 -0
  26. package/assets/commands/pipelines/aristotle.md +143 -0
  27. package/assets/commands/pipelines/ship.md +188 -0
  28. package/assets/commands/workflows/prompt-audit.md +37 -747
  29. package/dist/cli.js +251 -207
  30. package/dist/harnesses/claude-code.d.ts +8 -0
  31. package/dist/harnesses/claude-code.js +72 -0
  32. package/dist/harnesses/codex.d.ts +15 -0
  33. package/dist/harnesses/codex.js +53 -0
  34. package/dist/harnesses/gemini-cli.d.ts +16 -0
  35. package/dist/harnesses/gemini-cli.js +54 -0
  36. package/dist/harnesses/index.d.ts +18 -0
  37. package/dist/harnesses/index.js +45 -0
  38. package/dist/harnesses/opencode.d.ts +14 -0
  39. package/dist/harnesses/opencode.js +130 -0
  40. package/dist/harnesses/types.d.ts +87 -0
  41. package/dist/harnesses/types.js +24 -0
  42. package/dist/lib/agent-transform.d.ts +12 -0
  43. package/dist/lib/agent-transform.js +129 -0
  44. package/dist/lib/asset-catalog.d.ts +9 -0
  45. package/dist/lib/asset-catalog.js +56 -0
  46. package/dist/lib/atomic-write.d.ts +11 -0
  47. package/dist/lib/atomic-write.js +28 -0
  48. package/dist/lib/config-merger.d.ts +7 -1
  49. package/dist/lib/config-merger.js +34 -5
  50. package/dist/lib/display.d.ts +14 -0
  51. package/dist/lib/display.js +66 -0
  52. package/dist/lib/file-ops.d.ts +6 -0
  53. package/dist/lib/file-ops.js +22 -1
  54. package/dist/lib/hash.d.ts +1 -0
  55. package/dist/lib/hash.js +1 -0
  56. package/dist/lib/health.d.ts +2 -0
  57. package/dist/lib/health.js +10 -0
  58. package/dist/lib/manifest.d.ts +22 -5
  59. package/dist/lib/manifest.js +148 -13
  60. package/dist/lib/paths.d.ts +15 -3
  61. package/dist/lib/paths.js +71 -13
  62. package/dist/lib/settings-merger.d.ts +9 -1
  63. package/dist/lib/settings-merger.js +45 -17
  64. package/dist/steps/agents.d.ts +5 -1
  65. package/dist/steps/agents.js +59 -9
  66. package/dist/steps/auth.js +26 -10
  67. package/dist/steps/commands.d.ts +6 -1
  68. package/dist/steps/commands.js +87 -9
  69. package/dist/steps/detect.d.ts +3 -0
  70. package/dist/steps/detect.js +7 -0
  71. package/dist/steps/mcp.d.ts +6 -2
  72. package/dist/steps/mcp.js +46 -21
  73. package/dist/steps/metrics.d.ts +14 -10
  74. package/dist/steps/metrics.js +59 -89
  75. package/dist/steps/shell.d.ts +2 -0
  76. package/dist/steps/shell.js +16 -9
  77. package/dist/steps/signup.d.ts +6 -3
  78. package/dist/steps/signup.js +26 -14
  79. package/dist/steps/verify.d.ts +2 -2
  80. package/dist/steps/verify.js +84 -117
  81. package/package.json +32 -7
  82. package/assets/commands/workflows/aristotle.md +0 -543
  83. package/assets/commands/workflows/ship.md +0 -721
  84. package/dist/test/auth.test.d.ts +0 -1
  85. package/dist/test/auth.test.js +0 -43
  86. package/dist/test/config-io.test.d.ts +0 -1
  87. package/dist/test/config-io.test.js +0 -56
  88. package/dist/test/config-merger.test.d.ts +0 -1
  89. package/dist/test/config-merger.test.js +0 -94
  90. package/dist/test/detect.test.d.ts +0 -1
  91. package/dist/test/detect.test.js +0 -25
  92. package/dist/test/file-ops.test.d.ts +0 -1
  93. package/dist/test/file-ops.test.js +0 -100
  94. package/dist/test/hash.test.d.ts +0 -1
  95. package/dist/test/hash.test.js +0 -14
  96. package/dist/test/manifest.test.d.ts +0 -1
  97. package/dist/test/manifest.test.js +0 -78
  98. package/dist/test/paths.test.d.ts +0 -1
  99. package/dist/test/paths.test.js +0 -30
  100. package/dist/test/settings-merger.test.d.ts +0 -1
  101. package/dist/test/settings-merger.test.js +0 -167
  102. package/dist/test/shell-profile.test.d.ts +0 -1
  103. package/dist/test/shell-profile.test.js +0 -40
  104. package/dist/test/shell.test.d.ts +0 -1
  105. package/dist/test/shell.test.js +0 -71
  106. package/dist/test/signup.test.d.ts +0 -1
  107. package/dist/test/signup.test.js +0 -83
package/dist/steps/mcp.js CHANGED
@@ -1,40 +1,65 @@
1
- import { readFile, writeFile, access } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { readConfig, mergeUluopsMcp, removeUluopsMcp, writeConfig, } from "../lib/config-merger.js";
4
- import { getClaudeJsonPath, getLocalMcpPath } from "../lib/paths.js";
5
- export async function installMcp(apiKey, scope, dryRun) {
6
- const configPath = scope === "global" ? getClaudeJsonPath() : getLocalMcpPath();
7
- const config = await readConfig(configPath);
8
- const merged = mergeUluopsMcp(config, apiKey);
1
+ import { readFile, access, mkdir, copyFile } from "node:fs/promises";
2
+ import { join, basename } from "node:path";
3
+ import { checkMcpPackageAvailability } from "../lib/config-merger.js";
4
+ import { findProjectRoot, getBackupDir } from "../lib/paths.js";
5
+ import { atomicWrite } from "../lib/atomic-write.js";
6
+ /** Write UluOps MCP server entries into a harness's config file. */
7
+ export async function installMcp(profile, apiKey, scope, dryRun) {
8
+ const configPath = scope === "global"
9
+ ? profile.paths.globalMcpConfig
10
+ : join(await findProjectRoot(), profile.paths.localMcpConfig);
11
+ const config = await profile.mcpConfig.read(configPath);
12
+ const merged = profile.mcpConfig.merge(config, apiKey);
13
+ const packageWarnings = [];
14
+ const { missing } = await checkMcpPackageAvailability();
15
+ if (missing.length > 0) {
16
+ packageWarnings.push(`npm packages not found in registry: ${missing.join(", ")}. MCP servers may fail to start.`);
17
+ }
9
18
  if (!dryRun) {
10
- await writeConfig(configPath, merged);
19
+ // Backup before first write
20
+ await backupConfig(profile.name, configPath);
21
+ await profile.mcpConfig.write(configPath, merged);
11
22
  }
12
- // If local scope in a git repo, add .mcp.json to .gitignore
13
23
  if (scope === "local" && !dryRun) {
14
- await addToGitignore();
24
+ await addToGitignore(profile.paths.localMcpConfig);
15
25
  }
16
- return { configPath, scope };
26
+ return { configPath, scope, packageWarnings };
27
+ }
28
+ /** Remove UluOps MCP server entries from the harness config. */
29
+ export async function uninstallMcp(profile, configPath) {
30
+ await backupConfig(profile.name, configPath);
31
+ const config = await profile.mcpConfig.read(configPath);
32
+ const cleaned = profile.mcpConfig.remove(config);
33
+ await profile.mcpConfig.write(configPath, cleaned);
17
34
  }
18
- export async function uninstallMcp(configPath) {
19
- const config = await readConfig(configPath);
20
- const cleaned = removeUluopsMcp(config);
21
- await writeConfig(configPath, cleaned);
35
+ async function backupConfig(harnessName, configPath) {
36
+ try {
37
+ await access(configPath);
38
+ }
39
+ catch {
40
+ return; // Nothing to back up
41
+ }
42
+ const backupDir = getBackupDir(harnessName);
43
+ await mkdir(backupDir, { recursive: true });
44
+ const filename = basename(configPath);
45
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
46
+ await copyFile(configPath, join(backupDir, `${filename}.${timestamp}.bak`));
22
47
  }
23
- async function addToGitignore() {
48
+ async function addToGitignore(localConfigFilename) {
24
49
  const gitignorePath = join(process.cwd(), ".gitignore");
25
50
  try {
26
51
  await access(join(process.cwd(), ".git"));
27
52
  }
28
53
  catch {
29
- return; // Not a git repo
54
+ return;
30
55
  }
31
56
  try {
32
57
  const content = await readFile(gitignorePath, "utf-8");
33
- if (content.includes(".mcp.json"))
58
+ if (content.includes(localConfigFilename))
34
59
  return;
35
- await writeFile(gitignorePath, content.trimEnd() + "\n.mcp.json\n");
60
+ await atomicWrite(gitignorePath, content.trimEnd() + `\n${localConfigFilename}\n`);
36
61
  }
37
62
  catch {
38
- await writeFile(gitignorePath, ".mcp.json\n");
63
+ await atomicWrite(gitignorePath, `${localConfigFilename}\n`);
39
64
  }
40
65
  }
@@ -1,22 +1,26 @@
1
1
  /**
2
2
  * Metrics Step
3
3
  *
4
- * Installs agent-metrics tool files to ~/.claude/tools/agent-metrics/
5
- * and configures the SubagentStop hook in settings.json for auto-capture.
4
+ * Installs agent-metrics tool files and configures post-agent hooks.
5
+ * Only active for harnesses that support hooks (currently Claude Code only).
6
6
  */
7
- /** Where agent-metrics dist files are installed */
8
- export declare function getMetricsToolDir(): string;
9
- /** Path to Claude Code's settings.json */
10
- export declare function getSettingsPath(): string;
7
+ import type { HarnessProfile } from "../harnesses/index.js";
8
+ /**
9
+ * The hook command that runs on SubagentStop.
10
+ * @internal Exported for testing only — not part of the public API.
11
+ */
12
+ export declare function getHookCommand(profile: HarnessProfile): string;
11
13
  export interface MetricsResult {
12
14
  toolFilesCopied: number;
13
15
  hookConfigured: boolean;
16
+ skippedReason?: string;
14
17
  }
15
18
  /**
16
- * Install agent-metrics: copy tool files and configure SubagentStop hook.
19
+ * Install agent-metrics: copy tool files and configure hook.
20
+ * Skips entirely if the harness doesn't support hooks.
17
21
  */
18
- export declare function installMetrics(dryRun: boolean): Promise<MetricsResult>;
22
+ export declare function installMetrics(profile: HarnessProfile, dryRun: boolean): Promise<MetricsResult>;
19
23
  /**
20
- * Uninstall agent-metrics: remove hook from settings and optionally remove tool files.
24
+ * Uninstall agent-metrics: remove hook and tool files.
21
25
  */
22
- export declare function uninstallMetrics(dryRun: boolean): Promise<void>;
26
+ export declare function uninstallMetrics(profile: HarnessProfile, dryRun: boolean): Promise<void>;
@@ -1,25 +1,24 @@
1
1
  /**
2
2
  * Metrics Step
3
3
  *
4
- * Installs agent-metrics tool files to ~/.claude/tools/agent-metrics/
5
- * and configures the SubagentStop hook in settings.json for auto-capture.
4
+ * Installs agent-metrics tool files and configures post-agent hooks.
5
+ * Only active for harnesses that support hooks (currently Claude Code only).
6
6
  */
7
7
  import { mkdir, readdir, copyFile, rm, access } from "node:fs/promises";
8
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");
9
+ /** Where agent-metrics dist files are installed (derived from profile) */
10
+ function getMetricsToolDir(profile) {
11
+ return profile.paths.toolsDir;
14
12
  }
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")}`;
13
+ /**
14
+ * The hook command that runs on SubagentStop.
15
+ * @internal Exported for testing only — not part of the public API.
16
+ */
17
+ export function getHookCommand(profile) {
18
+ const toolDir = getMetricsToolDir(profile);
19
+ if (!toolDir)
20
+ throw new Error("No tool dir for this harness");
21
+ return `"${process.execPath}" "${join(toolDir, "dist", "hook.js")}"`;
23
22
  }
24
23
  /**
25
24
  * Find the agent-metrics package source directory.
@@ -43,62 +42,37 @@ async function findMetricsSource() {
43
42
  * Copy agent-metrics dist files to the tool directory.
44
43
  * Copies all .js files needed for the hook and CLI.
45
44
  */
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
45
+ /** Copy .js files from a source dir to a dest dir, skipping test files. */
46
+ async function copyJsDir(srcDir, destDir, dryRun) {
47
+ let count = 0;
70
48
  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."))
49
+ const files = await readdir(srcDir);
50
+ for (const file of files) {
51
+ if (!file.endsWith(".js") || file.includes(".test.") || file === "test-utils.js")
76
52
  continue;
77
- if (!dryRun) {
78
- await copyFile(join(srcDist, "commands", file), join(destDist, "commands", file));
79
- }
80
- filesCopied++;
53
+ if (!dryRun)
54
+ await copyFile(join(srcDir, file), join(destDir, file));
55
+ count++;
81
56
  }
82
57
  }
83
58
  catch {
84
- // commands/ doesn't exist — not critical
59
+ // Directory doesn't exist — not critical
85
60
  }
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++;
61
+ return count;
62
+ }
63
+ async function copyToolFiles(srcRoot, destRoot, dryRun) {
64
+ const srcDist = join(srcRoot, "dist");
65
+ const destDist = join(destRoot, "dist");
66
+ const subDirs = ["commands", "display"];
67
+ if (!dryRun) {
68
+ await mkdir(destDist, { recursive: true });
69
+ for (const sub of subDirs) {
70
+ await mkdir(join(destDist, sub), { recursive: true });
98
71
  }
99
72
  }
100
- catch {
101
- // display/ doesn't exist not critical
73
+ let filesCopied = await copyJsDir(srcDist, destDist, dryRun);
74
+ for (const sub of subDirs) {
75
+ filesCopied += await copyJsDir(join(srcDist, sub), join(destDist, sub), dryRun);
102
76
  }
103
77
  // Copy package.json (needed for CLI bin resolution)
104
78
  try {
@@ -113,61 +87,57 @@ async function copyToolFiles(srcRoot, destRoot, dryRun) {
113
87
  return filesCopied;
114
88
  }
115
89
  /**
116
- * Install agent-metrics: copy tool files and configure SubagentStop hook.
90
+ * Install agent-metrics: copy tool files and configure hook.
91
+ * Skips entirely if the harness doesn't support hooks.
117
92
  */
118
- export async function installMetrics(dryRun) {
119
- const toolDir = getMetricsToolDir();
120
- const settingsPath = getSettingsPath();
121
- // Find source package
93
+ export async function installMetrics(profile, dryRun) {
94
+ if (!profile.hooks || !profile.paths.toolsDir || !profile.paths.settingsPath) {
95
+ return { toolFilesCopied: 0, hookConfigured: false, skippedReason: "no-hook-support" };
96
+ }
97
+ const toolDir = profile.paths.toolsDir;
98
+ const settingsPath = profile.paths.settingsPath;
122
99
  const srcRoot = await findMetricsSource();
123
100
  let toolFilesCopied = 0;
124
101
  if (srcRoot) {
125
- // Copy tool files
126
102
  if (!dryRun) {
127
103
  await mkdir(toolDir, { recursive: true });
128
104
  }
129
105
  toolFilesCopied = await copyToolFiles(srcRoot, toolDir, dryRun);
130
106
  }
131
107
  else {
132
- // Check if already installed (from previous run or install.sh)
133
108
  try {
134
109
  await access(join(toolDir, "dist", "hook.js"));
135
110
  }
136
111
  catch {
137
- // Not found anywhere — skip tool installation, just configure hook
138
- // if files happen to exist
112
+ // Not found anywhere
139
113
  }
140
114
  }
141
- // Configure hook in settings.json
142
115
  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);
116
+ const hookJsPath = join(toolDir, "dist", "hook.js");
117
+ const hookJsExists = await access(hookJsPath).then(() => true, () => false);
118
+ if (hookJsExists && !dryRun) {
119
+ const hookCommand = getHookCommand(profile);
120
+ await profile.hooks.install(settingsPath, hookCommand, false);
148
121
  hookConfigured = true;
149
122
  }
150
- else {
123
+ else if (hookJsExists && dryRun) {
151
124
  hookConfigured = true;
152
125
  }
153
126
  return { toolFilesCopied, hookConfigured };
154
127
  }
155
128
  /**
156
- * Uninstall agent-metrics: remove hook from settings and optionally remove tool files.
129
+ * Uninstall agent-metrics: remove hook and tool files.
157
130
  */
158
- export async function uninstallMetrics(dryRun) {
159
- const settingsPath = getSettingsPath();
160
- const toolDir = getMetricsToolDir();
161
- // Remove hook from settings.json
131
+ export async function uninstallMetrics(profile, dryRun) {
132
+ if (!profile.hooks || !profile.paths.toolsDir || !profile.paths.settingsPath) {
133
+ return;
134
+ }
162
135
  if (!dryRun) {
163
- const settings = await readSettings(settingsPath);
164
- const cleaned = removeUluopsHook(settings);
165
- await writeSettings(settingsPath, cleaned);
136
+ await profile.hooks.remove(profile.paths.settingsPath, false);
166
137
  }
167
- // Remove tool directory
168
138
  if (!dryRun) {
169
139
  try {
170
- await rm(toolDir, { recursive: true, force: true });
140
+ await rm(profile.paths.toolsDir, { recursive: true, force: true });
171
141
  }
172
142
  catch {
173
143
  // Already gone
@@ -1,2 +1,4 @@
1
+ /** Write a fenced ULUOPS_API_KEY export block into the user's shell profile, replacing any existing UluOps block. */
1
2
  export declare function writeShellExport(profilePath: string, apiKey: string, dryRun: boolean): Promise<void>;
3
+ /** Remove the fenced UluOps export block from the user's shell profile. */
2
4
  export declare function removeShellExport(profilePath: string): Promise<void>;
@@ -1,7 +1,14 @@
1
- import { readFile, writeFile } from "node:fs/promises";
1
+ import { readFile } from "node:fs/promises";
2
+ import { atomicWrite } from "../lib/atomic-write.js";
2
3
  const FENCE_START = "# --- UluOps (managed by @uluops/setup) ---";
3
4
  const FENCE_END = "# --- /UluOps ---";
5
+ /** Characters safe for shell variable values (no metacharacters). */
6
+ const SAFE_KEY_PATTERN = /^[a-zA-Z0-9_\-\.]+$/;
7
+ /** Write a fenced ULUOPS_API_KEY export block into the user's shell profile, replacing any existing UluOps block. */
4
8
  export async function writeShellExport(profilePath, apiKey, dryRun) {
9
+ if (!SAFE_KEY_PATTERN.test(apiKey)) {
10
+ throw new Error("API key contains characters unsafe for shell export. Only alphanumeric, underscore, hyphen, and dot are allowed.");
11
+ }
5
12
  const block = `${FENCE_START}\nexport ULUOPS_API_KEY="${apiKey}"\n${FENCE_END}`;
6
13
  let content;
7
14
  try {
@@ -9,27 +16,27 @@ export async function writeShellExport(profilePath, apiKey, dryRun) {
9
16
  }
10
17
  catch {
11
18
  if (!dryRun) {
12
- await writeFile(profilePath, block + "\n");
19
+ await atomicWrite(profilePath, block + "\n");
13
20
  }
14
21
  return;
15
22
  }
16
23
  const startIdx = content.indexOf(FENCE_START);
17
24
  const endIdx = content.indexOf(FENCE_END);
18
- if (startIdx !== -1 && endIdx !== -1) {
19
- // Replace existing fenced block
25
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
26
+ // Replace existing fenced block (use last FENCE_END after FENCE_START to handle duplicates)
20
27
  const before = content.slice(0, startIdx);
21
28
  const after = content.slice(endIdx + FENCE_END.length);
22
29
  if (!dryRun) {
23
- await writeFile(profilePath, before + block + after);
30
+ await atomicWrite(profilePath, before + block + after);
24
31
  }
25
32
  }
26
33
  else {
27
- // Append
28
34
  if (!dryRun) {
29
- await writeFile(profilePath, content.trimEnd() + "\n\n" + block + "\n");
35
+ await atomicWrite(profilePath, content.trimEnd() + "\n\n" + block + "\n");
30
36
  }
31
37
  }
32
38
  }
39
+ /** Remove the fenced UluOps export block from the user's shell profile. */
33
40
  export async function removeShellExport(profilePath) {
34
41
  let content;
35
42
  try {
@@ -40,9 +47,9 @@ export async function removeShellExport(profilePath) {
40
47
  }
41
48
  const startIdx = content.indexOf(FENCE_START);
42
49
  const endIdx = content.indexOf(FENCE_END);
43
- if (startIdx !== -1 && endIdx !== -1) {
50
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
44
51
  const before = content.slice(0, startIdx);
45
52
  const after = content.slice(endIdx + FENCE_END.length);
46
- await writeFile(profilePath, (before + after).replace(/\n{3,}/g, "\n\n"));
53
+ await atomicWrite(profilePath, (before + after).replace(/\n{3,}/g, "\n\n"));
47
54
  }
48
55
  }
@@ -1,13 +1,16 @@
1
1
  import type { AuthResult } from "./auth.js";
2
2
  /**
3
- * Password complexity rules (matches ops-uluops-api validation).
4
- * Validated client-side for instant feedback before network round-trip.
3
+ * Advisory password hints. Returns warning strings for inquirer display,
4
+ * but all are non-blocking server validation is the authority.
5
+ * @internal Exported for testing only — not part of the public API.
5
6
  */
6
- declare function validatePassword(password: string): string | true;
7
+ declare function validatePassword(password: string): true;
8
+ /** @internal Exported for testing only — not part of the public API. */
7
9
  declare function validateEmail(email: string): string | true;
8
10
  /**
9
11
  * Interactive signup flow: create account + generate API key.
10
12
  * Returns the same AuthResult shape as resolveApiKey for seamless integration.
11
13
  */
12
14
  export declare function signup(): Promise<AuthResult>;
15
+ /** @internal Exported for testing only — not part of the public API. */
13
16
  export { validatePassword, validateEmail };
@@ -1,21 +1,23 @@
1
1
  const API_BASE = "https://api.uluops.ai/api/v1";
2
2
  /**
3
- * Password complexity rules (matches ops-uluops-api validation).
4
- * Validated client-side for instant feedback before network round-trip.
3
+ * Advisory password hints. Returns warning strings for inquirer display,
4
+ * but all are non-blocking server validation is the authority.
5
+ * @internal Exported for testing only — not part of the public API.
5
6
  */
6
7
  function validatePassword(password) {
7
8
  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";
9
+ console.warn(" Hint: server may require at least 8 characters");
10
+ else if (password.length > 128)
11
+ console.warn(" Hint: server may reject passwords over 128 characters");
12
+ else if (!/[a-z]/.test(password))
13
+ console.warn(" Hint: server may require a lowercase letter");
14
+ else if (!/[A-Z]/.test(password))
15
+ console.warn(" Hint: server may require an uppercase letter");
16
+ else if (!/[0-9]/.test(password))
17
+ console.warn(" Hint: server may require a number");
17
18
  return true;
18
19
  }
20
+ /** @internal Exported for testing only — not part of the public API. */
19
21
  function validateEmail(email) {
20
22
  if (!email.trim())
21
23
  return "Email is required";
@@ -40,12 +42,18 @@ export async function signup() {
40
42
  });
41
43
  // Register
42
44
  const registerRes = await callApi(`${API_BASE}/auth/register`, "POST", { email, password: pwd });
45
+ if (!registerRes.data?.sessionToken) {
46
+ throw new Error("Registration succeeded but response missing session token");
47
+ }
43
48
  const sessionToken = registerRes.data.sessionToken;
44
49
  // Create API key using the session
45
50
  const keyRes = await callApi(`${API_BASE}/auth/keys`, "POST", { name: "Setup CLI" }, sessionToken);
51
+ if (!keyRes.data?.key) {
52
+ throw new Error("API key creation succeeded but response missing key");
53
+ }
46
54
  return {
47
55
  apiKey: keyRes.data.key,
48
- email: registerRes.data.user.email,
56
+ email: registerRes.data.user?.email ?? email,
49
57
  };
50
58
  }
51
59
  async function callApi(url, method, body, bearerToken) {
@@ -71,7 +79,11 @@ async function callApi(url, method, body, bearerToken) {
71
79
  throw err;
72
80
  }
73
81
  if (res.ok) {
74
- return (await res.json());
82
+ const body = await res.json();
83
+ if (typeof body !== "object" || body === null) {
84
+ throw new Error("Unexpected API response shape");
85
+ }
86
+ return body;
75
87
  }
76
88
  // Handle known error codes
77
89
  const errorBody = await res.json().catch(() => null);
@@ -88,5 +100,5 @@ async function callApi(url, method, body, bearerToken) {
88
100
  }
89
101
  throw new Error(`Signup failed (${res.status}): ${message}`);
90
102
  }
91
- // Exported for testing
103
+ /** @internal Exported for testing only — not part of the public API. */
92
104
  export { validatePassword, validateEmail };
@@ -1,4 +1,4 @@
1
- interface VerifyResult {
1
+ export interface VerifyResult {
2
2
  ok: boolean;
3
3
  checks: {
4
4
  label: string;
@@ -6,5 +6,5 @@ interface VerifyResult {
6
6
  detail?: string;
7
7
  }[];
8
8
  }
9
+ /** Run all verification checks against the current installation and return structured results. */
9
10
  export declare function verify(): Promise<VerifyResult>;
10
- export {};