@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/lib/paths.js CHANGED
@@ -1,33 +1,91 @@
1
1
  import { homedir, platform } from "node:os";
2
- import { join, dirname } from "node:path";
2
+ import { join, dirname, isAbsolute } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { access } from "node:fs/promises";
4
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
5
6
  /** Root of the npm package (where assets/ lives) */
6
7
  export const PACKAGE_ROOT = join(__dirname, "..", "..");
7
8
  /** Assets directory containing pre-rendered .md files */
8
9
  export const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
10
+ /** Explicit project root override via --project-root flag or env var. */
11
+ let projectRootOverride = null;
12
+ export function setProjectRoot(path) {
13
+ projectRootOverride = path;
14
+ }
15
+ /** Walk upward from cwd to find the nearest directory containing .git or package.json. Falls back to cwd. */
16
+ export async function findProjectRoot() {
17
+ if (projectRootOverride)
18
+ return projectRootOverride;
19
+ const envRoot = process.env["ULUOPS_PROJECT_ROOT"];
20
+ if (envRoot) {
21
+ if (!isAbsolute(envRoot)) {
22
+ throw new Error(`ULUOPS_PROJECT_ROOT must be an absolute path, got: ${envRoot}`);
23
+ }
24
+ if (envRoot.includes("..")) {
25
+ throw new Error(`ULUOPS_PROJECT_ROOT must not contain traversal sequences: ${envRoot}`);
26
+ }
27
+ return envRoot;
28
+ }
29
+ let dir = process.cwd();
30
+ const root = dirname(dir);
31
+ while (dir !== root) {
32
+ if (await isProjectMarker(dir))
33
+ return dir;
34
+ dir = dirname(dir);
35
+ }
36
+ if (await isProjectMarker(dir))
37
+ return dir;
38
+ return process.cwd();
39
+ }
40
+ async function isProjectMarker(dir) {
41
+ try {
42
+ await access(join(dir, ".git"));
43
+ return true;
44
+ }
45
+ catch {
46
+ // continue
47
+ }
48
+ try {
49
+ await access(join(dir, "package.json"));
50
+ return true;
51
+ }
52
+ catch {
53
+ // continue
54
+ }
55
+ return false;
56
+ }
57
+ /** Return the Claude config home directory (~/.claude by default, or CLAUDE_HOME env override). */
9
58
  export function getClaudeHome() {
10
- return join(homedir(), ".claude");
59
+ return process.env["CLAUDE_HOME"] ?? join(homedir(), ".claude");
11
60
  }
61
+ /** Return the path to Claude's global config file (~/.claude.json by default, or CLAUDE_JSON_PATH env override). */
12
62
  export function getClaudeJsonPath() {
63
+ const envPath = process.env["CLAUDE_JSON_PATH"];
64
+ if (envPath)
65
+ return envPath;
13
66
  return join(homedir(), ".claude.json");
14
67
  }
15
- export function getLocalMcpPath() {
16
- return join(process.cwd(), ".mcp.json");
68
+ /** Return the path to the project-local MCP config file (.mcp.json in project root). */
69
+ export async function getLocalMcpPath() {
70
+ return join(await findProjectRoot(), ".mcp.json");
17
71
  }
72
+ /** Return the UluOps state directory (~/.uluops/). Harness-neutral. */
73
+ export function getUluopsDir() {
74
+ return join(homedir(), ".uluops");
75
+ }
76
+ /** Return the path to the UluOps install manifest file (new location). */
18
77
  export function getManifestPath() {
19
- return join(getClaudeHome(), "uluops-manifest.json");
78
+ return join(getUluopsDir(), "manifest.json");
20
79
  }
21
- export function getAgentsDir(localDefs) {
22
- if (localDefs)
23
- return join(process.cwd(), "uluops", "agents");
24
- return join(getClaudeHome(), "agents");
80
+ /** Return the legacy manifest path for migration. */
81
+ export function getLegacyManifestPath() {
82
+ return join(getClaudeHome(), "uluops-manifest.json");
25
83
  }
26
- export function getCommandsDir(localDefs) {
27
- if (localDefs)
28
- return join(process.cwd(), "uluops", "commands");
29
- return join(getClaudeHome(), "commands");
84
+ /** Return the backup directory for a harness's config files. */
85
+ export function getBackupDir(harnessName) {
86
+ return join(getUluopsDir(), "backups", harnessName);
30
87
  }
88
+ /** Detect the user's shell and return its name and profile path, or null if unsupported. */
31
89
  export function getShellProfile() {
32
90
  const shell = process.env["SHELL"] ?? "";
33
91
  const home = homedir();
@@ -13,13 +13,21 @@ interface HookMatcher {
13
13
  matcher?: string;
14
14
  hooks: HookEntry[];
15
15
  }
16
- interface ClaudeSettings {
16
+ export interface ClaudeSettings {
17
17
  permissions?: Record<string, unknown>;
18
18
  hooks?: Record<string, HookMatcher[]>;
19
19
  [key: string]: unknown;
20
20
  }
21
+ export interface HookProbeResult {
22
+ hookType: string;
23
+ supported: boolean;
24
+ warning?: string;
25
+ }
26
+ /** Check whether the configured hook event type is in the known supported set. Returns the resolved hook type and a warning if unsupported. */
27
+ export declare function probeHookSupport(): HookProbeResult;
21
28
  /**
22
29
  * Read an existing settings.json, or return empty object if it doesn't exist.
30
+ * Throws on malformed JSON to prevent silent data loss during merge+write.
23
31
  */
24
32
  export declare function readSettings(path: string): Promise<ClaudeSettings>;
25
33
  /**
@@ -4,37 +4,63 @@
4
4
  * Safe read/merge/remove for Claude Code's settings.json.
5
5
  * Only touches UluOps-managed hook entries — all other settings preserved.
6
6
  */
7
- import { readFile, writeFile } from "node:fs/promises";
7
+ import { readFile } from "node:fs/promises";
8
+ import { atomicWrite } from "./atomic-write.js";
8
9
  /** Marker embedded in hook commands to identify UluOps-managed entries */
9
10
  const ULUOPS_HOOK_MARKER = "tools/agent-metrics";
11
+ /** Supported hook event types in Claude Code. Update when Claude Code adds/renames types. */
12
+ const SUPPORTED_HOOK_TYPES = new Set([
13
+ "SubagentStop",
14
+ "PreToolUse",
15
+ "PostToolUse",
16
+ "Notification",
17
+ "Stop",
18
+ ]);
19
+ /** Configurable hook type via env var. Falls back to SubagentStop. */
20
+ function getHookEventType() {
21
+ return process.env["ULUOPS_HOOK_TYPE"] ?? "SubagentStop";
22
+ }
23
+ /** Check whether the configured hook event type is in the known supported set. Returns the resolved hook type and a warning if unsupported. */
24
+ export function probeHookSupport() {
25
+ const hookType = getHookEventType();
26
+ if (SUPPORTED_HOOK_TYPES.has(hookType)) {
27
+ return { hookType, supported: true };
28
+ }
29
+ return {
30
+ hookType,
31
+ supported: false,
32
+ warning: `Hook type "${hookType}" is not in the known supported set {${[...SUPPORTED_HOOK_TYPES].join(", ")}}. Metrics may silently fail if this hook type does not exist in Claude Code.`,
33
+ };
34
+ }
10
35
  /**
11
36
  * Read an existing settings.json, or return empty object if it doesn't exist.
37
+ * Throws on malformed JSON to prevent silent data loss during merge+write.
12
38
  */
13
39
  export async function readSettings(path) {
40
+ let raw;
14
41
  try {
15
- const raw = await readFile(path, "utf-8");
16
- return JSON.parse(raw);
42
+ raw = await readFile(path, "utf-8");
17
43
  }
18
44
  catch {
19
- return {};
45
+ return {}; // File doesn't exist — fresh config
20
46
  }
47
+ return JSON.parse(raw);
21
48
  }
22
49
  /**
23
50
  * Write settings back to file with stable formatting.
24
51
  */
25
52
  export async function writeSettings(path, settings) {
26
- await writeFile(path, JSON.stringify(settings, null, 2) + "\n");
53
+ await atomicWrite(path, JSON.stringify(settings, null, 2) + "\n");
27
54
  }
28
55
  /**
29
56
  * Merge the UluOps SubagentStop hook into settings, preserving all other
30
57
  * hooks and settings. If a UluOps hook already exists, it is replaced.
31
58
  */
32
59
  export function mergeUluopsHook(settings, hookCommand) {
60
+ const hookType = getHookEventType();
33
61
  const hooks = settings.hooks ?? {};
34
- const existing = hooks["SubagentStop"] ?? [];
35
- // Remove any existing UluOps hook entries
62
+ const existing = hooks[hookType] ?? [];
36
63
  const filtered = existing.filter((m) => !m.hooks.some((h) => h.command.includes(ULUOPS_HOOK_MARKER)));
37
- // Add the new UluOps hook
38
64
  const uluopsHook = {
39
65
  hooks: [
40
66
  {
@@ -48,7 +74,7 @@ export function mergeUluopsHook(settings, hookCommand) {
48
74
  ...settings,
49
75
  hooks: {
50
76
  ...hooks,
51
- SubagentStop: [...filtered, uluopsHook],
77
+ [hookType]: [...filtered, uluopsHook],
52
78
  },
53
79
  };
54
80
  }
@@ -57,19 +83,20 @@ export function mergeUluopsHook(settings, hookCommand) {
57
83
  * the key is removed. If hooks becomes empty, the key is removed.
58
84
  */
59
85
  export function removeUluopsHook(settings) {
86
+ const hookType = getHookEventType();
60
87
  const hooks = settings.hooks;
61
88
  if (!hooks)
62
89
  return settings;
63
- const subagentStop = hooks["SubagentStop"];
64
- if (!subagentStop)
90
+ const hookEntries = hooks[hookType];
91
+ if (!hookEntries)
65
92
  return settings;
66
- const filtered = subagentStop.filter((m) => !m.hooks.some((h) => h.command.includes(ULUOPS_HOOK_MARKER)));
93
+ const filtered = hookEntries.filter((m) => !m.hooks.some((h) => h.command.includes(ULUOPS_HOOK_MARKER)));
67
94
  const updatedHooks = { ...hooks };
68
95
  if (filtered.length === 0) {
69
- delete updatedHooks["SubagentStop"];
96
+ delete updatedHooks[hookType];
70
97
  }
71
98
  else {
72
- updatedHooks["SubagentStop"] = filtered;
99
+ updatedHooks[hookType] = filtered;
73
100
  }
74
101
  const result = { ...settings };
75
102
  if (Object.keys(updatedHooks).length === 0) {
@@ -84,8 +111,9 @@ export function removeUluopsHook(settings) {
84
111
  * Check if a UluOps hook is configured in settings.
85
112
  */
86
113
  export function hasUluopsHook(settings) {
87
- const subagentStop = settings.hooks?.["SubagentStop"];
88
- if (!subagentStop)
114
+ const hookType = getHookEventType();
115
+ const hookEntries = settings.hooks?.[hookType];
116
+ if (!hookEntries)
89
117
  return false;
90
- return subagentStop.some((m) => m.hooks.some((h) => h.command.includes(ULUOPS_HOOK_MARKER)));
118
+ return hookEntries.some((m) => m.hooks.some((h) => h.command.includes(ULUOPS_HOOK_MARKER)));
91
119
  }
@@ -1,8 +1,12 @@
1
+ import type { HarnessProfile } from "../harnesses/index.js";
1
2
  export interface AgentsResult {
2
3
  copied: number;
3
4
  skipped: number;
4
5
  removed: number;
5
6
  files: string[];
6
7
  }
7
- export declare function installAgents(localDefs: boolean, dryRun: boolean, existingManifestAgents?: string[]): Promise<AgentsResult>;
8
+ /** Copy agent definition files from assets to the harness agents directory,
9
+ * transforming frontmatter to match the target harness format. */
10
+ export declare function installAgents(profile: HarnessProfile, localDefs: boolean, dryRun: boolean, existingManifestAgents?: string[]): Promise<AgentsResult>;
11
+ /** Remove previously installed agent files by name. */
8
12
  export declare function uninstallAgents(files: string[], defsPath: string): Promise<number>;
@@ -1,14 +1,64 @@
1
+ import { readFile, readdir, mkdir, unlink } from "node:fs/promises";
1
2
  import { join } from "node:path";
2
- import { ASSETS_DIR, getAgentsDir } from "../lib/paths.js";
3
- import { syncAssets, unlinkFiles } from "../lib/file-ops.js";
4
- export async function installAgents(localDefs, dryRun, existingManifestAgents) {
5
- return syncAssets({
6
- srcDir: join(ASSETS_DIR, "agents"),
7
- destDir: getAgentsDir(localDefs),
8
- dryRun,
9
- oldManifestFiles: existingManifestAgents,
10
- });
3
+ import { ASSETS_DIR, findProjectRoot } from "../lib/paths.js";
4
+ import { copyIfChanged, writeIfChanged, unlinkFiles } from "../lib/file-ops.js";
5
+ import { transformAgent } from "../lib/agent-transform.js";
6
+ /** Source directory for agent assets (single set, Claude Code format). */
7
+ const AGENTS_SRC = join(ASSETS_DIR, "agents");
8
+ /** Copy agent definition files from assets to the harness agents directory,
9
+ * transforming frontmatter to match the target harness format. */
10
+ export async function installAgents(profile, localDefs, dryRun, existingManifestAgents) {
11
+ const destDir = localDefs
12
+ ? join(await findProjectRoot(), "uluops", "agents")
13
+ : profile.paths.agentsDir;
14
+ if (!dryRun) {
15
+ await mkdir(destDir, { recursive: true });
16
+ }
17
+ const needsTransform = profile.name !== "claude-code";
18
+ let files;
19
+ try {
20
+ files = (await readdir(AGENTS_SRC)).filter((f) => f.endsWith(".md"));
21
+ }
22
+ catch {
23
+ return { copied: 0, skipped: 0, removed: 0, files: [] };
24
+ }
25
+ let copied = 0;
26
+ let skipped = 0;
27
+ for (const file of files) {
28
+ let result;
29
+ if (needsTransform) {
30
+ const markdown = await readFile(join(AGENTS_SRC, file), "utf-8");
31
+ const transformed = transformAgent(markdown, profile.name);
32
+ result = await writeIfChanged(join(destDir, file), transformed, dryRun);
33
+ }
34
+ else {
35
+ result = await copyIfChanged(join(AGENTS_SRC, file), join(destDir, file), dryRun);
36
+ }
37
+ if (result === "copied")
38
+ copied++;
39
+ else
40
+ skipped++;
41
+ }
42
+ // Remove files that were in the old manifest but no longer in the package
43
+ let removed = 0;
44
+ if (existingManifestAgents) {
45
+ for (const oldFile of existingManifestAgents) {
46
+ if (!files.includes(oldFile)) {
47
+ if (!dryRun) {
48
+ try {
49
+ await unlink(join(destDir, oldFile));
50
+ }
51
+ catch {
52
+ // Already gone
53
+ }
54
+ }
55
+ removed++;
56
+ }
57
+ }
58
+ }
59
+ return { copied, skipped, removed, files };
11
60
  }
61
+ /** Remove previously installed agent files by name. */
12
62
  export async function uninstallAgents(files, defsPath) {
13
63
  return unlinkFiles(join(defsPath, "agents"), files);
14
64
  }
@@ -1,6 +1,9 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ function getKeyPrefix() {
5
+ return process.env["ULUOPS_KEY_PREFIX"] ?? "ulr_";
6
+ }
4
7
  /**
5
8
  * Resolve API key from flags, env, credentials file, or interactive prompt.
6
9
  */
@@ -20,20 +23,22 @@ export async function resolveApiKey(options) {
20
23
  if (!options.interactive) {
21
24
  throw new Error("No API key found. Pass --api-key or set ULUOPS_API_KEY. Get one at app.uluops.ai/settings/api-keys");
22
25
  }
26
+ const prefix = getKeyPrefix();
23
27
  const { input } = await import("@inquirer/prompts");
24
28
  apiKey = await input({
25
29
  message: "Enter your UluOps API key",
26
30
  validate: (val) => {
27
31
  if (!val.trim())
28
32
  return "Get a key at app.uluops.ai/settings/api-keys";
29
- if (!val.startsWith("ulr_"))
30
- return "API keys start with ulr_get one at app.uluops.ai/settings/api-keys";
33
+ if (!val.startsWith(prefix))
34
+ return `API keys typically start with ${prefix}if your key has a different format, just paste it and server validation will check it`;
31
35
  return true;
32
36
  },
33
37
  });
34
38
  }
35
- if (!apiKey?.startsWith("ulr_")) {
36
- throw new Error("API keys start with ulr_ — get one at app.uluops.ai/settings/api-keys");
39
+ const prefix = getKeyPrefix();
40
+ if (!apiKey.startsWith(prefix) && !options.skipValidation) {
41
+ process.stderr.write(` ⚠ Key does not start with expected prefix "${prefix}" — proceeding with server validation\n`);
37
42
  }
38
43
  // Validate against server
39
44
  if (!options.skipValidation) {
@@ -43,22 +48,33 @@ export async function resolveApiKey(options) {
43
48
  return { apiKey, email: null };
44
49
  }
45
50
  async function readCredentialsFile() {
51
+ const credsPath = join(homedir(), ".uluops", "credentials.json");
52
+ let raw;
46
53
  try {
47
- const credsPath = join(homedir(), ".uluops", "credentials.json");
48
- const raw = await readFile(credsPath, "utf-8");
49
- const creds = JSON.parse(raw);
50
- const defaultProfile = creds["default"];
51
- return defaultProfile?.apiKey ?? defaultProfile?.api_key;
54
+ raw = await readFile(credsPath, "utf-8");
52
55
  }
53
56
  catch {
54
- return undefined;
57
+ return undefined; // File doesn't exist
58
+ }
59
+ let creds;
60
+ try {
61
+ creds = JSON.parse(raw);
55
62
  }
63
+ catch (err) {
64
+ throw new Error(`Malformed credentials file at ${credsPath}: ${err instanceof Error ? err.message : "invalid JSON"}`);
65
+ }
66
+ if (typeof creds !== "object" || creds === null)
67
+ return undefined;
68
+ const profiles = creds;
69
+ const defaultProfile = profiles["default"];
70
+ return defaultProfile?.apiKey ?? defaultProfile?.api_key;
56
71
  }
57
72
  async function validateKey(apiKey) {
58
73
  const url = "https://api.uluops.ai/api/v1/registry/users/me";
59
74
  try {
60
75
  const res = await fetch(url, {
61
76
  headers: { Authorization: `Bearer ${apiKey}` },
77
+ signal: AbortSignal.timeout(15000),
62
78
  });
63
79
  if (res.status === 401) {
64
80
  throw new Error("Invalid API key — generate a new one at app.uluops.ai/settings/api-keys");
@@ -1,9 +1,14 @@
1
+ import type { HarnessProfile } from "../harnesses/index.js";
1
2
  export interface CommandsResult {
2
3
  agentCommands: number;
3
4
  workflowCommands: number;
5
+ pipelineCommands: number;
4
6
  skipped: number;
5
7
  removed: number;
6
8
  files: string[];
9
+ skippedReason?: string;
7
10
  }
8
- export declare function installCommands(localDefs: boolean, dryRun: boolean, existingManifestCommands?: string[]): Promise<CommandsResult>;
11
+ /** Install slash-command files, transforming to target format as needed. */
12
+ export declare function installCommands(profile: HarnessProfile, localDefs: boolean, dryRun: boolean, existingManifestCommands?: string[]): Promise<CommandsResult>;
13
+ /** Remove previously installed command files by relative path. Returns count of successfully removed files. */
9
14
  export declare function uninstallCommands(files: string[], defsPath: string): Promise<number>;
@@ -1,13 +1,75 @@
1
- import { readdir, mkdir, unlink } from "node:fs/promises";
1
+ import { readFile, readdir, mkdir, unlink } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { ASSETS_DIR, getCommandsDir } from "../lib/paths.js";
4
- import { copyIfChanged } from "../lib/file-ops.js";
5
- const SUBDIRS = ["agents", "workflows"];
6
- export async function installCommands(localDefs, dryRun, existingManifestCommands) {
3
+ import { ASSETS_DIR, findProjectRoot } from "../lib/paths.js";
4
+ import { copyIfChanged, writeIfChanged } from "../lib/file-ops.js";
5
+ const SUBDIRS = ["agents", "workflows", "pipelines"];
6
+ /** Harnesses that support command installation. */
7
+ const SUPPORTED_HARNESSES = new Set(["claude-code", "gemini-cli"]);
8
+ // --- Gemini CLI transform ---
9
+ /**
10
+ * Strip YAML frontmatter from rendered markdown, returning just the body.
11
+ */
12
+ function stripFrontmatter(markdown) {
13
+ const first = markdown.indexOf("---");
14
+ if (first === -1)
15
+ return markdown;
16
+ const second = markdown.indexOf("---", first + 3);
17
+ if (second === -1)
18
+ return markdown;
19
+ return markdown.substring(second + 3);
20
+ }
21
+ /**
22
+ * Escape a string for use in a TOML basic string (double-quoted).
23
+ */
24
+ function escapeToml(value) {
25
+ return value
26
+ .replace(/\\/g, "\\\\")
27
+ .replace(/"/g, '\\"')
28
+ .replace(/\n/g, "\\n")
29
+ .replace(/\r/g, "\\r")
30
+ .replace(/\t/g, "\\t");
31
+ }
32
+ /**
33
+ * Transform a Claude Code markdown command to a Gemini CLI TOML command.
34
+ * Strips frontmatter, substitutes $ARGUMENTS → {{args}}, wraps in TOML.
35
+ */
36
+ function transformToGeminiToml(markdown, description) {
37
+ const body = stripFrontmatter(markdown)
38
+ .replace(/\$ARGUMENTS/g, "{{args}}")
39
+ .trim();
40
+ // Escape """ in body for TOML multi-line strings
41
+ const escaped = body.replace(/"""/g, '""\\"');
42
+ return `description = "${escapeToml(description)}"\nprompt = """\n${escaped}\n"""\n`;
43
+ }
44
+ /**
45
+ * Extract the description from a markdown command's YAML frontmatter.
46
+ */
47
+ function extractDescription(markdown) {
48
+ const match = markdown.match(/^description:\s*(.+)$/m);
49
+ return match?.[1]?.trim() ?? "";
50
+ }
51
+ // --- Install ---
52
+ /** Install slash-command files, transforming to target format as needed. */
53
+ export async function installCommands(profile, localDefs, dryRun, existingManifestCommands) {
54
+ if (!SUPPORTED_HARNESSES.has(profile.name)) {
55
+ return {
56
+ agentCommands: 0,
57
+ workflowCommands: 0,
58
+ pipelineCommands: 0,
59
+ skipped: 0,
60
+ removed: 0,
61
+ files: [],
62
+ skippedReason: "not-supported",
63
+ };
64
+ }
7
65
  const srcBase = join(ASSETS_DIR, "commands");
8
- const destBase = getCommandsDir(localDefs);
66
+ const destBase = localDefs
67
+ ? join(await findProjectRoot(), "uluops", "commands")
68
+ : profile.paths.commandsDir;
69
+ const needsTransform = profile.name === "gemini-cli";
9
70
  let agentCommands = 0;
10
71
  let workflowCommands = 0;
72
+ let pipelineCommands = 0;
11
73
  let skipped = 0;
12
74
  const allFiles = [];
13
75
  for (const subdir of SUBDIRS) {
@@ -24,13 +86,27 @@ export async function installCommands(localDefs, dryRun, existingManifestCommand
24
86
  continue;
25
87
  }
26
88
  for (const file of files) {
27
- const relativePath = `${subdir}/${file}`;
28
- const result = await copyIfChanged(join(srcDir, file), join(destDir, file), dryRun);
89
+ const destFile = needsTransform
90
+ ? file.replace(/\.md$/, ".toml")
91
+ : file;
92
+ const relativePath = `${subdir}/${destFile}`;
93
+ let result;
94
+ if (needsTransform) {
95
+ const markdown = await readFile(join(srcDir, file), "utf-8");
96
+ const description = extractDescription(markdown);
97
+ const toml = transformToGeminiToml(markdown, description);
98
+ result = await writeIfChanged(join(destDir, destFile), toml, dryRun);
99
+ }
100
+ else {
101
+ result = await copyIfChanged(join(srcDir, file), join(destDir, destFile), dryRun);
102
+ }
29
103
  if (result === "copied") {
30
104
  if (subdir === "agents")
31
105
  agentCommands++;
32
- else
106
+ else if (subdir === "workflows")
33
107
  workflowCommands++;
108
+ else
109
+ pipelineCommands++;
34
110
  }
35
111
  else {
36
112
  skipped++;
@@ -58,11 +134,13 @@ export async function installCommands(localDefs, dryRun, existingManifestCommand
58
134
  return {
59
135
  agentCommands,
60
136
  workflowCommands,
137
+ pipelineCommands,
61
138
  skipped,
62
139
  removed,
63
140
  files: allFiles,
64
141
  };
65
142
  }
143
+ /** Remove previously installed command files by relative path. Returns count of successfully removed files. */
66
144
  export async function uninstallCommands(files, defsPath) {
67
145
  const { unlinkFiles } = await import("../lib/file-ops.js");
68
146
  return unlinkFiles(join(defsPath, "commands"), files);
@@ -1,3 +1,4 @@
1
+ import type { HarnessProfile } from "../harnesses/index.js";
1
2
  export interface Environment {
2
3
  os: "linux" | "darwin" | "win32";
3
4
  isWsl: boolean;
@@ -5,5 +6,7 @@ export interface Environment {
5
6
  shellProfile: string | null;
6
7
  nodeVersion: string;
7
8
  claudeHomeExists: boolean;
9
+ detectedHarnesses: HarnessProfile[];
8
10
  }
11
+ /** Detect the current environment: OS, shell, Node version, harnesses, and Claude home status. */
9
12
  export declare function detect(): Promise<Environment>;
@@ -1,13 +1,18 @@
1
1
  import { platform, release } from "node:os";
2
2
  import { access } from "node:fs/promises";
3
3
  import { getClaudeHome, getShellProfile } from "../lib/paths.js";
4
+ import { detectHarnesses } from "../harnesses/index.js";
4
5
  const SUPPORTED_PLATFORMS = new Set(["linux", "darwin", "win32"]);
6
+ /** Detect the current environment: OS, shell, Node version, harnesses, and Claude home status. */
5
7
  export async function detect() {
6
8
  const p = platform();
7
9
  if (!SUPPORTED_PLATFORMS.has(p)) {
8
10
  throw new Error(`Unsupported platform: ${p}. Expected linux, darwin, or win32.`);
9
11
  }
10
12
  const os = p;
13
+ if (os === "win32") {
14
+ throw new Error("Windows (native) is not supported. Please use WSL2 (Ubuntu) and run setup inside WSL.");
15
+ }
11
16
  const isWsl = os === "linux" && release().toLowerCase().includes("microsoft");
12
17
  const profile = getShellProfile();
13
18
  const nodeVersion = process.version;
@@ -19,6 +24,7 @@ export async function detect() {
19
24
  catch {
20
25
  // Does not exist
21
26
  }
27
+ const detectedHarnesses = detectHarnesses();
22
28
  return {
23
29
  os,
24
30
  isWsl,
@@ -26,5 +32,6 @@ export async function detect() {
26
32
  shellProfile: profile?.path ?? null,
27
33
  nodeVersion,
28
34
  claudeHomeExists,
35
+ detectedHarnesses,
29
36
  };
30
37
  }
@@ -1,6 +1,10 @@
1
+ import type { HarnessProfile } from "../harnesses/index.js";
1
2
  export interface McpResult {
2
3
  configPath: string;
3
4
  scope: "global" | "local";
5
+ packageWarnings: string[];
4
6
  }
5
- export declare function installMcp(apiKey: string, scope: "global" | "local", dryRun: boolean): Promise<McpResult>;
6
- export declare function uninstallMcp(configPath: string): Promise<void>;
7
+ /** Write UluOps MCP server entries into a harness's config file. */
8
+ export declare function installMcp(profile: HarnessProfile, apiKey: string, scope: "global" | "local", dryRun: boolean): Promise<McpResult>;
9
+ /** Remove UluOps MCP server entries from the harness config. */
10
+ export declare function uninstallMcp(profile: HarnessProfile, configPath: string): Promise<void>;