@goodtek/vibeops 1.0.0 → 1.1.2

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 (43) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +51 -60
  3. package/dist/bootstrap/installer.js +38 -4
  4. package/dist/bootstrap/manifest.js +17 -7
  5. package/dist/cli.js +64 -15
  6. package/dist/commands/init.js +279 -114
  7. package/dist/commands/llm.js +177 -0
  8. package/dist/commands/status.js +37 -1
  9. package/dist/commands/task-add.js +33 -6
  10. package/dist/commands/task-done.js +100 -31
  11. package/dist/lib/config.js +84 -4
  12. package/dist/lib/env.js +32 -0
  13. package/dist/lib/git-config.js +21 -0
  14. package/dist/lib/git-host.js +12 -0
  15. package/dist/lib/git-policy.js +67 -0
  16. package/dist/lib/git-remote.js +108 -0
  17. package/dist/lib/git.js +43 -0
  18. package/dist/lib/init-clients.js +55 -0
  19. package/dist/lib/llm-complete.js +18 -19
  20. package/dist/lib/llm-connect.js +150 -0
  21. package/dist/lib/llm-preference.js +102 -0
  22. package/dist/lib/llm-status.js +19 -0
  23. package/dist/lib/pr-create.js +66 -0
  24. package/dist/lib/task-add-llm.js +8 -1
  25. package/dist/lib/task-done-llm.js +8 -1
  26. package/dist/lib/task-pr-llm.js +50 -0
  27. package/dist/lib/task-push-pr.js +131 -0
  28. package/dist/lib/task-start.js +46 -18
  29. package/dist/lib/task.js +15 -0
  30. package/package.json +1 -1
  31. package/templates/clients/claude/CLAUDE.md +28 -0
  32. package/templates/{AGENTS.md → core/AGENTS.md} +16 -9
  33. package/templates/{.cursor/skills → skills-shared}/implement-task/SKILL.md +1 -1
  34. package/templates/{.cursor/skills → skills-shared}/plan-task/SKILL.md +3 -3
  35. package/dist/lib/task-merge.js +0 -115
  36. /package/templates/{.cursor → clients/cursor/.cursor}/rules/01-task-source-of-truth.mdc +0 -0
  37. /package/templates/{.cursor → clients/cursor/.cursor}/rules/02-git-safety.mdc +0 -0
  38. /package/templates/{.cursor → clients/cursor/.cursor}/rules/03-docs-on-done.mdc +0 -0
  39. /package/templates/{docs → core/docs}/logs/README.md +0 -0
  40. /package/templates/{docs → core/docs}/project/03-architecture.md +0 -0
  41. /package/templates/{docs → core/docs}/project/05-current-state.md +0 -0
  42. /package/templates/{docs → core/docs}/project/06-decisions.md +0 -0
  43. /package/templates/{docs → core/docs}/tasks/TASK-000-template.md +0 -0
@@ -0,0 +1,108 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { askInput } from "./inquirer-helpers.js";
4
+ import { detectGitHost } from "./git-host.js";
5
+ import { gitRemoteUrl, runGit } from "./git.js";
6
+ import { dim, log } from "./logger.js";
7
+ const execFileAsync = promisify(execFile);
8
+ async function probeGh() {
9
+ try {
10
+ await execFileAsync("gh", ["auth", "status"], { maxBuffer: 1024 * 1024 });
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ async function probeGlab() {
18
+ try {
19
+ await execFileAsync("glab", ["auth", "status"], { maxBuffer: 1024 * 1024 });
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ async function createGithubRepo(cwd, name) {
27
+ const { stdout } = await execFileAsync("gh",
28
+ // gh requires an explicit visibility flag when non-interactive.
29
+ ["repo", "create", name, "--source=.", "--remote=origin", "--push", "--private"], { cwd, maxBuffer: 8 * 1024 * 1024 });
30
+ const match = stdout.match(/https:\/\/github\.com\/[^\s]+/);
31
+ if (match)
32
+ return match[0].replace(/\.git$/, "") + ".git";
33
+ const url = await gitRemoteUrl(cwd, "origin");
34
+ if (url)
35
+ return url;
36
+ throw new Error("gh repo create finished but origin URL could not be read.");
37
+ }
38
+ async function createGitlabRepo(cwd, name, defaultBranch) {
39
+ await execFileAsync("glab", ["repo", "create", name, "--remote=origin", "--defaultBranch", defaultBranch], { cwd, maxBuffer: 8 * 1024 * 1024 });
40
+ const url = await gitRemoteUrl(cwd, "origin");
41
+ if (url)
42
+ return url;
43
+ throw new Error("glab repo create finished but origin URL could not be read.");
44
+ }
45
+ /**
46
+ * Ensures `origin` (or named remote) exists. Interactive path can create via gh/glab or manual URL.
47
+ */
48
+ export async function ensureOriginRemote(opts) {
49
+ const remote = opts.remoteName ?? "origin";
50
+ const existing = await gitRemoteUrl(opts.cwd, remote);
51
+ if (existing) {
52
+ const host = detectGitHost(existing);
53
+ if (!host) {
54
+ log.warn(`Remote ${remote} URL is not GitHub or GitLab: ${existing}`);
55
+ if (opts.allowMissing)
56
+ return null;
57
+ throw new Error("Only GitHub and GitLab remotes are supported.");
58
+ }
59
+ return { remote, url: existing, host };
60
+ }
61
+ if (opts.dryRun) {
62
+ log.info(dim(`would configure git remote ${remote}`));
63
+ return { remote, url: "(dry-run)", host: "github" };
64
+ }
65
+ if (opts.allowMissing) {
66
+ return null;
67
+ }
68
+ if (opts.nonInteractive) {
69
+ throw new Error(`Git remote "${remote}" is not configured. Add it with: git remote add ${remote} <url>`);
70
+ }
71
+ log.blank();
72
+ log.info("Remote origin is required for push and merge requests.");
73
+ const ghOk = await probeGh();
74
+ const glabOk = await probeGlab();
75
+ if (ghOk) {
76
+ const name = await askInput({
77
+ message: "GitHub repository name (owner/repo or name for gh to create)",
78
+ nonInteractive: false,
79
+ required: true,
80
+ });
81
+ log.info(dim("Creating GitHub repository via gh…"));
82
+ const url = await createGithubRepo(opts.cwd, name);
83
+ return { remote, url, host: "github" };
84
+ }
85
+ if (glabOk) {
86
+ const name = await askInput({
87
+ message: "GitLab project path (group/project)",
88
+ nonInteractive: false,
89
+ required: true,
90
+ });
91
+ log.info(dim("Creating GitLab project via glab…"));
92
+ const defaultBranch = typeof opts.defaultBranch === "string" && opts.defaultBranch.length > 0
93
+ ? opts.defaultBranch
94
+ : "main";
95
+ const url = await createGitlabRepo(opts.cwd, name, defaultBranch);
96
+ return { remote, url, host: "gitlab" };
97
+ }
98
+ const url = await askInput({
99
+ message: `Remote URL for ${remote} (https://github.com/… or https://gitlab.com/…)`,
100
+ nonInteractive: false,
101
+ required: true,
102
+ });
103
+ await runGit(opts.cwd, ["remote", "add", remote, url.trim()]);
104
+ const host = detectGitHost(url);
105
+ if (!host)
106
+ throw new Error("Only GitHub and GitLab HTTPS/SSH URLs are supported.");
107
+ return { remote, url: url.trim(), host };
108
+ }
package/dist/lib/git.js CHANGED
@@ -83,6 +83,36 @@ export async function gitBranchExists(cwd, name) {
83
83
  const res = await tryGit(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${name}`]);
84
84
  return res !== null;
85
85
  }
86
+ export async function gitRemoteBranchExists(cwd, remote, branch) {
87
+ const res = await tryGit(cwd, [
88
+ "show-ref",
89
+ "--verify",
90
+ "--quiet",
91
+ `refs/remotes/${remote}/${branch}`,
92
+ ]);
93
+ return res !== null;
94
+ }
95
+ /**
96
+ * Checkout local `branch`, creating it from `remote/branch` when only the remote ref exists.
97
+ */
98
+ export async function gitSwitchToBranch(cwd, branch, remote = "origin") {
99
+ if (await gitBranchExists(cwd, branch)) {
100
+ await gitCheckout(cwd, branch);
101
+ return true;
102
+ }
103
+ if ((await gitRemoteUrl(cwd, remote)) === null)
104
+ return false;
105
+ try {
106
+ await gitFetch(cwd, remote, branch);
107
+ }
108
+ catch {
109
+ // branch may not exist on remote yet
110
+ }
111
+ if (!(await gitRemoteBranchExists(cwd, remote, branch)))
112
+ return false;
113
+ await gitCheckoutNewBranch(cwd, branch, `${remote}/${branch}`);
114
+ return true;
115
+ }
86
116
  export async function gitCreateBranch(cwd, name, startPoint) {
87
117
  const args = ["branch", name];
88
118
  if (typeof startPoint === "string" && startPoint.length > 0)
@@ -459,3 +489,16 @@ export async function gitRemoteUrl(cwd, name = "origin") {
459
489
  const url = res.stdout.trim();
460
490
  return url.length > 0 ? url : null;
461
491
  }
492
+ export async function gitFetch(cwd, remote, ref) {
493
+ const args = ["fetch", remote];
494
+ if (typeof ref === "string" && ref.length > 0)
495
+ args.push(ref);
496
+ await runGit(cwd, args);
497
+ }
498
+ export async function gitPullFastForwardOnly(cwd, remote, branch) {
499
+ await runGit(cwd, ["pull", "--ff-only", remote, branch]);
500
+ }
501
+ export async function gitPush(cwd, remote, branch, setUpstream = true) {
502
+ const args = setUpstream ? ["push", "-u", remote, branch] : ["push", remote, branch];
503
+ await runGit(cwd, args);
504
+ }
@@ -0,0 +1,55 @@
1
+ import { checkbox } from "@inquirer/prompts";
2
+ export const VIBEOPS_CLIENT_IDS = ["cursor", "claude", "codex"];
3
+ export const CLIENT_LABELS = {
4
+ cursor: "Cursor (.cursor/rules, .cursor/skills)",
5
+ claude: "Claude Code (CLAUDE.md, .claude/skills)",
6
+ codex: "Codex CLI (.agents/skills)",
7
+ };
8
+ /** Relative skill root per client (under project root). */
9
+ export const CLIENT_SKILL_ROOT = {
10
+ cursor: ".cursor/skills",
11
+ claude: ".claude/skills",
12
+ codex: ".agents/skills",
13
+ };
14
+ export function parseClientsArg(raw) {
15
+ if (raw === undefined || raw.trim().length === 0)
16
+ return null;
17
+ const parts = raw.split(",").map((p) => p.trim().toLowerCase());
18
+ const out = [];
19
+ for (const p of parts) {
20
+ const id = parseOneClient(p);
21
+ if (id === null)
22
+ return null;
23
+ if (!out.includes(id))
24
+ out.push(id);
25
+ }
26
+ return out.length > 0 ? out : null;
27
+ }
28
+ function parseOneClient(token) {
29
+ if (token === "cursor")
30
+ return "cursor";
31
+ if (token === "claude" || token === "claude-code" || token === "claudecode")
32
+ return "claude";
33
+ if (token === "codex" || token === "codex-cli")
34
+ return "codex";
35
+ return null;
36
+ }
37
+ export function isVibeopsClientId(value) {
38
+ return VIBEOPS_CLIENT_IDS.includes(value);
39
+ }
40
+ export async function askInitClients() {
41
+ const selected = await checkbox({
42
+ message: "Which coding agents will you use? (Space toggle · Enter confirm)",
43
+ choices: VIBEOPS_CLIENT_IDS.map((id) => ({
44
+ name: CLIENT_LABELS[id],
45
+ value: id,
46
+ checked: id === "cursor",
47
+ })),
48
+ loop: false,
49
+ validate: (answer) => answer.length > 0 ? true : "Select at least one agent (Cursor, Claude Code, and/or Codex).",
50
+ });
51
+ return selected;
52
+ }
53
+ export function formatClientsList(clients) {
54
+ return clients.map((c) => CLIENT_LABELS[c]).join(", ");
55
+ }
@@ -1,30 +1,20 @@
1
- import { probeCodexOAuthFile } from "./plan-codex-auth.js";
1
+ import { resolveAvailableLlmProvider, resolveLlmProviderForUse } from "./llm-preference.js";
2
2
  import { codexOAuthTextCompletion } from "./plan-codex-responses.js";
3
3
  import { cursorAgentPrint, extractAgentAssistantText } from "./plan-llm-cursor-agent.js";
4
- import { probeCursorAgentCli, probeOpenAiApiKey } from "./plan-llm-detect.js";
5
4
  import { extractJsonObject, openAiChatCompletionJson, openAiChatCompletionText, } from "./plan-llm-openai.js";
6
- /** Non-interactive: Codex OAuth → Cursor Agent → OpenAI API key (same sources as `vibeops plan`). */
7
- export async function resolveAvailableLlmProvider(cwd) {
8
- const codex = await probeCodexOAuthFile();
9
- if (codex.ok)
10
- return "codex-oauth";
11
- const cursor = await probeCursorAgentCli(cwd);
12
- if (cursor.ok)
13
- return "cursor-agent";
14
- const openai = await probeOpenAiApiKey();
15
- if (openai.ok)
16
- return "openai";
17
- return null;
18
- }
19
5
  function formatMessagesForCursor(messages) {
20
6
  return messages
21
7
  .map((m) => `### ${m.role}\n\n${m.content}`)
22
8
  .join("\n\n---\n\n");
23
9
  }
24
10
  export async function llmCompleteText(messages, opts) {
25
- const provider = opts.provider ?? (await resolveAvailableLlmProvider(opts.cwd));
11
+ const pref = opts.preference ?? "auto";
12
+ const provider = opts.provider ?? (await resolveLlmProviderForUse(opts.cwd, pref));
26
13
  if (provider === null) {
27
- throw new Error("No LLM provider available. Use Codex OAuth (~/.codex/auth.json), Cursor Agent CLI (`agent login`), or OPENAI_API_KEY.");
14
+ if (pref !== "auto") {
15
+ throw new Error(`Preferred LLM "${pref}" is not available (not installed or not authenticated). Run \`vibeops llm connect\` or \`vibeops llm use auto\`.`);
16
+ }
17
+ throw new Error("No LLM provider available. Run `vibeops llm connect` or set Codex OAuth, Cursor Agent CLI (`agent login`), or OPENAI_API_KEY.");
28
18
  }
29
19
  if (provider === "openai") {
30
20
  const text = await openAiChatCompletionText({ messages, model: opts.model });
@@ -42,9 +32,13 @@ export async function llmCompleteText(messages, opts) {
42
32
  return { text: extractAgentAssistantText(raw), provider: "cursor-agent" };
43
33
  }
44
34
  export async function llmCompleteJson(messages, opts) {
45
- const provider = opts.provider ?? (await resolveAvailableLlmProvider(opts.cwd));
35
+ const pref = opts.preference ?? "auto";
36
+ const provider = opts.provider ?? (await resolveLlmProviderForUse(opts.cwd, pref));
46
37
  if (provider === null) {
47
- throw new Error("No LLM provider available. Use Codex OAuth (~/.codex/auth.json), Cursor Agent CLI (`agent login`), or OPENAI_API_KEY.");
38
+ if (pref !== "auto") {
39
+ throw new Error(`Preferred LLM "${pref}" is not available (not installed or not authenticated). Run \`vibeops llm connect\` or \`vibeops llm use auto\`.`);
40
+ }
41
+ throw new Error("No LLM provider available. Run `vibeops llm connect` or set Codex OAuth, Cursor Agent CLI (`agent login`), or OPENAI_API_KEY.");
48
42
  }
49
43
  if (provider === "openai") {
50
44
  const text = await openAiChatCompletionJson({ messages, model: opts.model });
@@ -60,3 +54,8 @@ export async function llmCompleteJson(messages, opts) {
60
54
  const { text: raw } = await llmCompleteText(jsonMessages, { ...opts, provider });
61
55
  return { text: extractJsonObject(raw), provider };
62
56
  }
57
+ export { resolveAvailableLlmProvider } from "./llm-preference.js";
58
+ /** True if any provider can run completions (ignores project preference). */
59
+ export async function anyLlmProviderAvailable(cwd) {
60
+ return (await resolveAvailableLlmProvider(cwd)) !== null;
61
+ }
@@ -0,0 +1,150 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, appendFile, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { dim, log } from "./logger.js";
5
+ import { codexAuthJsonPath, probeCodexOAuthFile } from "./plan-codex-auth.js";
6
+ import { probeCursorAgentCli, probeOpenAiApiKey } from "./plan-llm-detect.js";
7
+ import { VIBEOPS_ENV_FILE } from "./paths.js";
8
+ function agentBin() {
9
+ return process.env.VIBEOPS_CURSOR_AGENT_BIN?.trim() || "agent";
10
+ }
11
+ async function fileExists(path) {
12
+ try {
13
+ await access(path);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ export async function runCursorAgentLogin(cwd) {
21
+ const command = agentBin();
22
+ const before = await probeCursorAgentCli(cwd);
23
+ if (before.reason?.includes("not found on PATH")) {
24
+ return {
25
+ ok: false,
26
+ message: [
27
+ `Command "${command}" is not on PATH.`,
28
+ "Install Cursor Agent CLI: https://cursor.com/docs/cli",
29
+ "Then rerun: vibeops llm connect",
30
+ ].join("\n"),
31
+ };
32
+ }
33
+ log.info(`Running ${command} login — complete auth in the browser if prompted.`);
34
+ log.blank();
35
+ return await new Promise((resolve) => {
36
+ const child = spawn(command, ["login"], {
37
+ cwd,
38
+ stdio: "inherit",
39
+ env: process.env,
40
+ shell: process.platform === "win32",
41
+ });
42
+ child.on("error", (err) => {
43
+ if (err.code === "ENOENT") {
44
+ resolve({
45
+ ok: false,
46
+ message: `Command "${command}" not found. Install from https://cursor.com/docs/cli`,
47
+ });
48
+ return;
49
+ }
50
+ resolve({ ok: false, message: err.message });
51
+ });
52
+ child.on("close", async (code) => {
53
+ const after = await probeCursorAgentCli(cwd);
54
+ if (after.ok) {
55
+ resolve({ ok: true, message: "Cursor Agent CLI is authenticated." });
56
+ return;
57
+ }
58
+ if (code === 0) {
59
+ resolve({
60
+ ok: false,
61
+ message: after.reason ?? "Login finished but `agent status` still reports not authenticated.",
62
+ });
63
+ return;
64
+ }
65
+ resolve({
66
+ ok: false,
67
+ message: after.reason ?? `${command} login exited with code ${code ?? "unknown"}.`,
68
+ });
69
+ });
70
+ });
71
+ }
72
+ export async function guideCodexOAuth() {
73
+ const path = codexAuthJsonPath();
74
+ const probe = await probeCodexOAuthFile();
75
+ if (probe.ok) {
76
+ return { ok: true, message: `Codex OAuth already configured at ${path}` };
77
+ }
78
+ return {
79
+ ok: false,
80
+ message: [
81
+ "Codex OAuth is not configured yet.",
82
+ "",
83
+ "Option A — official Codex CLI:",
84
+ " codex login",
85
+ "",
86
+ "Option B — copy auth from another machine:",
87
+ ` Place auth.json at ${path}`,
88
+ "",
89
+ "Then rerun: vibeops llm connect",
90
+ ].join("\n"),
91
+ };
92
+ }
93
+ export async function configureOpenAiKey(projectRoot, apiKey) {
94
+ const trimmed = apiKey.trim();
95
+ const envPath = join(projectRoot, VIBEOPS_ENV_FILE);
96
+ if (trimmed.length === 0) {
97
+ return { ok: false, message: "API key cannot be empty.", envPath };
98
+ }
99
+ const line = `OPENAI_API_KEY=${trimmed}`;
100
+ if (await fileExists(envPath)) {
101
+ const existing = await readFile(envPath, "utf-8");
102
+ if (/^OPENAI_API_KEY=/m.test(existing)) {
103
+ const next = existing.replace(/^OPENAI_API_KEY=.*$/m, line);
104
+ await writeFile(envPath, next.endsWith("\n") ? next : `${next}\n`, "utf-8");
105
+ }
106
+ else {
107
+ await appendFile(envPath, existing.endsWith("\n") ? `${line}\n` : `\n${line}\n`, "utf-8");
108
+ }
109
+ }
110
+ else {
111
+ await writeFile(envPath, ["# VibeOps · optional environment (gitignored)", "# Never commit secrets.", "", line, ""].join("\n"), "utf-8");
112
+ }
113
+ process.env.OPENAI_API_KEY = trimmed;
114
+ const verify = await probeOpenAiApiKey();
115
+ if (!verify.ok) {
116
+ return {
117
+ ok: false,
118
+ message: verify.reason ?? "Key saved but OpenAI verification failed.",
119
+ envPath,
120
+ };
121
+ }
122
+ return {
123
+ ok: true,
124
+ message: `OpenAI API key saved to ${VIBEOPS_ENV_FILE} and verified.`,
125
+ envPath,
126
+ };
127
+ }
128
+ export function codexAuthHint() {
129
+ return codexAuthJsonPath();
130
+ }
131
+ export function cursorAgentHint() {
132
+ return agentBin();
133
+ }
134
+ export function openAiEnvHint(projectRoot) {
135
+ return join(projectRoot, VIBEOPS_ENV_FILE);
136
+ }
137
+ export function printConnectSetupHint(provider) {
138
+ switch (provider) {
139
+ case "cursor-agent":
140
+ log.info(dim(" https://cursor.com/docs/cli"));
141
+ log.info(dim(" Then: agent login (or vibeops llm connect → Set up Cursor Agent CLI)"));
142
+ break;
143
+ case "codex-oauth":
144
+ log.info(dim(` codex login → ${codexAuthHint()}`));
145
+ break;
146
+ case "openai":
147
+ log.info(dim(` vibeops llm connect → Set up OpenAI API key (writes ${VIBEOPS_ENV_FILE})`));
148
+ break;
149
+ }
150
+ }
@@ -0,0 +1,102 @@
1
+ import { relative } from "node:path";
2
+ import { probeCodexOAuthFile } from "./plan-codex-auth.js";
3
+ import { probeCursorAgentCli, probeOpenAiApiKey } from "./plan-llm-detect.js";
4
+ export const LLM_PROVIDER_ORDER = [
5
+ "codex-oauth",
6
+ "cursor-agent",
7
+ "openai",
8
+ ];
9
+ function shortPath(cwd, abs) {
10
+ const r = relative(cwd, abs);
11
+ return r.startsWith("..") || r.length === 0 ? abs : r;
12
+ }
13
+ /**
14
+ * First available provider in Codex → Cursor Agent → OpenAI order.
15
+ */
16
+ export async function resolveAvailableLlmProvider(cwd) {
17
+ const codex = await probeCodexOAuthFile();
18
+ if (codex.ok)
19
+ return "codex-oauth";
20
+ const cursor = await probeCursorAgentCli(cwd);
21
+ if (cursor.ok)
22
+ return "cursor-agent";
23
+ const openai = await probeOpenAiApiKey();
24
+ if (openai.ok)
25
+ return "openai";
26
+ return null;
27
+ }
28
+ export async function probeAllLlmProviders(cwd) {
29
+ const codex = await probeCodexOAuthFile();
30
+ const cursor = await probeCursorAgentCli(cwd);
31
+ const openai = await probeOpenAiApiKey();
32
+ return [
33
+ {
34
+ id: "codex-oauth",
35
+ ok: codex.ok,
36
+ summary: codex.ok ? `ok · ${shortPath(cwd, codex.path)}` : (codex.reason ?? "not configured"),
37
+ },
38
+ {
39
+ id: "cursor-agent",
40
+ ok: cursor.ok,
41
+ summary: cursor.ok ? `ok · command: ${cursor.command}` : (cursor.reason ?? "not configured"),
42
+ },
43
+ {
44
+ id: "openai",
45
+ ok: openai.ok,
46
+ summary: openai.ok ? "ok · OPENAI_API_KEY" : (openai.reason ?? "not configured"),
47
+ },
48
+ ];
49
+ }
50
+ export function parseLlmProviderPreference(raw) {
51
+ const t = raw.trim().toLowerCase();
52
+ if (t === "auto")
53
+ return "auto";
54
+ if (t === "codex-oauth" || t === "codex")
55
+ return "codex-oauth";
56
+ if (t === "cursor-agent" || t === "cursor")
57
+ return "cursor-agent";
58
+ if (t === "openai")
59
+ return "openai";
60
+ return null;
61
+ }
62
+ export function labelLlmProvider(id) {
63
+ switch (id) {
64
+ case "auto":
65
+ return "Auto (Codex → Cursor → OpenAI)";
66
+ case "codex-oauth":
67
+ return "Codex OAuth (~/.codex/auth.json)";
68
+ case "cursor-agent":
69
+ return "Cursor Agent CLI (`agent`)";
70
+ case "openai":
71
+ return "OpenAI API (OPENAI_API_KEY)";
72
+ default:
73
+ return id;
74
+ }
75
+ }
76
+ /**
77
+ * Resolves which concrete provider to use given project preference.
78
+ * - `auto` / undefined: same as `resolveAvailableLlmProvider`.
79
+ * - explicit: that provider only if its probe passes.
80
+ */
81
+ export async function resolveLlmProviderForUse(cwd, preference) {
82
+ const pref = preference ?? "auto";
83
+ if (pref === "auto") {
84
+ return resolveAvailableLlmProvider(cwd);
85
+ }
86
+ if (pref === "codex-oauth") {
87
+ const c = await probeCodexOAuthFile();
88
+ return c.ok ? "codex-oauth" : null;
89
+ }
90
+ if (pref === "cursor-agent") {
91
+ const c = await probeCursorAgentCli(cwd);
92
+ return c.ok ? "cursor-agent" : null;
93
+ }
94
+ const o = await probeOpenAiApiKey();
95
+ return o.ok ? "openai" : null;
96
+ }
97
+ export function getLlmPreferenceFromConfig(config) {
98
+ const p = config?.llm?.provider;
99
+ if (p === "auto" || p === "codex-oauth" || p === "cursor-agent" || p === "openai")
100
+ return p;
101
+ return "auto";
102
+ }
@@ -0,0 +1,19 @@
1
+ import { readConfig } from "./config.js";
2
+ import { getLlmPreferenceFromConfig, labelLlmProvider, probeAllLlmProviders, resolveLlmProviderForUse, } from "./llm-preference.js";
3
+ export async function buildLlmStatusReport(cwd) {
4
+ const config = await readConfig(cwd);
5
+ const preference = getLlmPreferenceFromConfig(config);
6
+ const providers = await probeAllLlmProviders(cwd);
7
+ const active = await resolveLlmProviderForUse(cwd, preference);
8
+ const anyAvailable = providers.some((p) => p.ok);
9
+ return { preference, active, providers, anyAvailable };
10
+ }
11
+ export function formatLlmStatusLine(report) {
12
+ const preferenceLabel = labelLlmProvider(report.preference);
13
+ const activeLabel = report.active !== null
14
+ ? `${labelLlmProvider(report.active)} (ok)`
15
+ : report.anyAvailable
16
+ ? "none (preference blocked or misconfigured)"
17
+ : "none — run vibeops llm connect";
18
+ return { preferenceLabel, activeLabel };
19
+ }
@@ -0,0 +1,66 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { mergeRequestLabel } from "./git-host.js";
4
+ import { log } from "./logger.js";
5
+ const execFileAsync = promisify(execFile);
6
+ async function ghPrCreate(opts) {
7
+ const { stdout } = await execFileAsync("gh", [
8
+ "pr",
9
+ "create",
10
+ "--base",
11
+ opts.baseBranch,
12
+ "--head",
13
+ opts.headBranch,
14
+ "--title",
15
+ opts.title,
16
+ "--body",
17
+ opts.body,
18
+ ], { cwd: opts.cwd, maxBuffer: 4 * 1024 * 1024 });
19
+ const line = stdout.trim().split("\n").pop()?.trim();
20
+ if (line && line.startsWith("http"))
21
+ return line;
22
+ throw new Error("gh pr create did not return a URL.");
23
+ }
24
+ async function glabMrCreate(opts) {
25
+ const { stdout } = await execFileAsync("glab", [
26
+ "mr",
27
+ "create",
28
+ "--target-branch",
29
+ opts.baseBranch,
30
+ "--source-branch",
31
+ opts.headBranch,
32
+ "--title",
33
+ opts.title,
34
+ "--description",
35
+ opts.body,
36
+ ], { cwd: opts.cwd, maxBuffer: 4 * 1024 * 1024 });
37
+ const line = stdout.trim().split("\n").pop()?.trim();
38
+ if (line && (line.startsWith("http") || line.includes("merge_requests")))
39
+ return line;
40
+ const match = stdout.match(/https?:\/\/[^\s]+/);
41
+ if (match)
42
+ return match[0];
43
+ throw new Error("glab mr create did not return a URL.");
44
+ }
45
+ export async function createMergeRequest(opts) {
46
+ const label = mergeRequestLabel(opts.host);
47
+ if (opts.dryRun) {
48
+ log.info(`would create ${label}: ${opts.headBranch} → ${opts.baseBranch}`);
49
+ return { url: `(dry-run ${label})` };
50
+ }
51
+ const url = opts.host === "gitlab" ? await glabMrCreate(opts) : await ghPrCreate(opts);
52
+ return { url };
53
+ }
54
+ export async function probeMergeRequestCli(host) {
55
+ try {
56
+ if (host === "gitlab") {
57
+ await execFileAsync("glab", ["--version"], { maxBuffer: 1024 });
58
+ return true;
59
+ }
60
+ await execFileAsync("gh", ["--version"], { maxBuffer: 1024 });
61
+ return true;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
@@ -1,6 +1,8 @@
1
1
  import { join, relative } from "node:path";
2
2
  import { readTextOrNull } from "./filesystem.js";
3
+ import { readConfig } from "./config.js";
3
4
  import { llmCompleteJson } from "./llm-complete.js";
5
+ import { getLlmPreferenceFromConfig } from "./llm-preference.js";
4
6
  import { projectPaths } from "./paths.js";
5
7
  import { slugify } from "./slug.js";
6
8
  import { buildMinimalTaskMarkdown, ensureV3Sections, normalizeTaskHeader, titleFromIdea, } from "./task-scaffold.js";
@@ -53,7 +55,12 @@ export async function llmScaffoldTask(params) {
53
55
  content: [`TASK id: ${params.taskId}`, `Idea: ${params.idea}`, "", context].join("\n"),
54
56
  },
55
57
  ];
56
- const { text: raw, provider } = await llmCompleteJson(messages, { cwd: params.cwd });
58
+ const config = await readConfig(params.cwd);
59
+ const preference = getLlmPreferenceFromConfig(config);
60
+ const { text: raw, provider } = await llmCompleteJson(messages, {
61
+ cwd: params.cwd,
62
+ preference,
63
+ });
57
64
  const parsed = JSON.parse(extractJsonObject(raw));
58
65
  const title = typeof parsed.title === "string"
59
66
  ? parsed.title.replace(/^TASK-\d+\s*[:\-]\s*/i, "").trim()
@@ -1,6 +1,8 @@
1
1
  import { join } from "node:path";
2
2
  import { readText, readTextOrNull, writeText } from "./filesystem.js";
3
+ import { readConfig } from "./config.js";
3
4
  import { llmCompleteJson } from "./llm-complete.js";
5
+ import { getLlmPreferenceFromConfig } from "./llm-preference.js";
4
6
  import { gitDiffNameOnly, readGitInfo } from "./git.js";
5
7
  import { projectPaths, PROJECT_MEMORY_FILES } from "./paths.js";
6
8
  import { extractJsonObject } from "./plan-llm-openai.js";
@@ -64,7 +66,12 @@ Be factual. Do not invent files not in the diff.`,
64
66
  },
65
67
  ];
66
68
  try {
67
- const { text: raw, provider } = await llmCompleteJson(messages, { cwd: params.cwd });
69
+ const config = await readConfig(params.cwd);
70
+ const preference = getLlmPreferenceFromConfig(config);
71
+ const { text: raw, provider } = await llmCompleteJson(messages, {
72
+ cwd: params.cwd,
73
+ preference,
74
+ });
68
75
  const parsed = JSON.parse(extractJsonObject(raw));
69
76
  const result = typeof parsed.result === "string" ? parsed.result.trim() : "";
70
77
  const testResult = typeof parsed.testResult === "string" ? parsed.testResult.trim() : "";