@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
@@ -1,10 +1,12 @@
1
1
  import { basename, resolve } from "node:path";
2
2
  import { install, printReport } from "../bootstrap/installer.js";
3
- import { buildConfig } from "../lib/config.js";
4
- import { currentBranchOrUnborn, gitAddAll, gitCommit, gitInit, gitSetDefaultBranch, gitStatusPorcelain, hasAnyCommit, isGitRepository, } from "../lib/git.js";
3
+ import { buildConfig, isVibeopsProject, readConfig, writeConfig } from "../lib/config.js";
4
+ import { gitAddAll, gitBranchExists, gitCheckout, gitCommit, gitCreateBranch, gitInit, gitPullFastForwardOnly, gitPush, gitRemoteBranchExists, gitRemoteUrl, gitSetDefaultBranch, gitStatusPorcelain, hasAnyCommit, isGitRepository, } from "../lib/git.js";
5
+ import { formatGitPolicySummary, askGitPolicy, parseGitPolicyArg, resolvePreset } from "../lib/git-policy.js";
6
+ import { ensureOriginRemote } from "../lib/git-remote.js";
7
+ import { askInitClients, formatClientsList, parseClientsArg, } from "../lib/init-clients.js";
5
8
  import { askInput, askYesNo } from "../lib/inquirer-helpers.js";
6
9
  import { cyan, dim, green, log, yellow } from "../lib/logger.js";
7
- const DEFAULT_BRANCH = "main";
8
10
  const DEFAULT_COMMIT_MESSAGE = "chore: initialize vibeops project";
9
11
  function deriveProjectName(root, explicit) {
10
12
  if (explicit && explicit.trim().length > 0)
@@ -14,124 +16,282 @@ function deriveProjectName(root, explicit) {
14
16
  return base;
15
17
  return "untitled-project";
16
18
  }
19
+ function parseGitHostArg(raw) {
20
+ if (raw === undefined || raw.trim().length === 0)
21
+ return null;
22
+ const t = raw.trim().toLowerCase();
23
+ if (t === "github" || t === "gitlab")
24
+ return t;
25
+ return null;
26
+ }
27
+ async function confirmReinitIfNeeded(projectRoot, options) {
28
+ const existing = await isVibeopsProject(projectRoot);
29
+ if (!existing)
30
+ return true;
31
+ if (options.dryRun) {
32
+ log.info(dim("(dry-run) would re-initialize existing VibeOps project templates"));
33
+ return true;
34
+ }
35
+ if (options.yes === true || options.force === true) {
36
+ log.warn("Re-initializing existing VibeOps project (templates will be overwritten).");
37
+ return true;
38
+ }
39
+ if (process.stdin.isTTY !== true) {
40
+ log.error("This directory is already a VibeOps project. Use --yes to re-run init, or --force.");
41
+ process.exitCode = 1;
42
+ return false;
43
+ }
44
+ log.warn(yellow("This directory is already a VibeOps project."));
45
+ log.info(dim("Re-running init overwrites AGENTS.md, client rules/skills, and project doc stubs. docs/tasks/TASK-*.md are kept."));
46
+ log.blank();
47
+ const proceed = await askYesNo({
48
+ message: "Continue and overwrite template files?",
49
+ nonInteractive: false,
50
+ defaultValue: false,
51
+ });
52
+ if (!proceed) {
53
+ log.info(dim("Stopped — no files changed."));
54
+ process.exitCode = 1;
55
+ return false;
56
+ }
57
+ return true;
58
+ }
59
+ async function resolveClients(options, interactive) {
60
+ const fromFlag = parseClientsArg(options.clients);
61
+ if (options.clients !== undefined && fromFlag === null) {
62
+ log.error('Invalid --clients. Use comma-separated: cursor, claude, codex (e.g. --clients cursor,claude)');
63
+ process.exitCode = 1;
64
+ return null;
65
+ }
66
+ if (fromFlag !== null)
67
+ return fromFlag;
68
+ if (interactive) {
69
+ log.blank();
70
+ log.info("Agent clients");
71
+ return askInitClients();
72
+ }
73
+ if (options.dryRun) {
74
+ return ["cursor"];
75
+ }
76
+ log.error("Select at least one agent: --clients cursor,claude,codex (non-interactive mode).");
77
+ process.exitCode = 1;
78
+ return null;
79
+ }
80
+ async function resolveBranchPolicy(options, interactive) {
81
+ const presetId = parseGitPolicyArg(options.gitPolicy);
82
+ if (options.gitPolicy !== undefined && presetId === null) {
83
+ log.error("Invalid --git-policy. Use: gitflow, trunk, or custom");
84
+ process.exitCode = 1;
85
+ return null;
86
+ }
87
+ if (typeof options.integrationBranch === "string" &&
88
+ typeof options.productionBranch === "string") {
89
+ return {
90
+ integrationBranch: options.integrationBranch.trim(),
91
+ productionBranch: options.productionBranch.trim(),
92
+ };
93
+ }
94
+ if (presetId !== null && presetId !== "custom") {
95
+ const p = resolvePreset(presetId);
96
+ return {
97
+ integrationBranch: p.integrationBranch,
98
+ productionBranch: p.productionBranch,
99
+ };
100
+ }
101
+ if (interactive) {
102
+ log.blank();
103
+ log.info("Branch policy");
104
+ return askGitPolicy();
105
+ }
106
+ if (options.dryRun) {
107
+ return { integrationBranch: "develop", productionBranch: "main" };
108
+ }
109
+ return { integrationBranch: "develop", productionBranch: "main" };
110
+ }
111
+ function printNextSteps(clients, git) {
112
+ log.blank();
113
+ log.info("Next steps:");
114
+ log.info(" 1. Read AGENTS.md");
115
+ if (clients.includes("cursor")) {
116
+ log.info(" 2. Cursor: @docs/tasks/TASK-001-….md in Ask, then Agent (+ /plan-task, /implement-task)");
117
+ }
118
+ if (clients.includes("claude")) {
119
+ log.info(" 2. Claude Code: open TASK file; use /plan-task then /implement-task");
120
+ }
121
+ if (clients.includes("codex")) {
122
+ log.info(" 2. Codex: open TASK file; use $plan-task / $implement-task when needed");
123
+ }
124
+ if (!clients.includes("cursor") && !clients.includes("claude") && !clients.includes("codex")) {
125
+ log.info(" 2. Open the current TASK file in your agent before coding");
126
+ }
127
+ log.info(` 3. ${cyan("vibeops llm connect")} — LLM for task add / task done`);
128
+ log.info(" 4. Push branches to origin (first time only):");
129
+ log.info(` ${dim(`git push -u ${git.remote} ${git.productionBranch}`)}`);
130
+ if (git.integrationBranch !== git.productionBranch) {
131
+ log.info(` ${dim(`git push -u ${git.remote} ${git.integrationBranch}`)}`);
132
+ }
133
+ log.info(` 5. ${cyan("vibeops task add")} — branches from ${git.integrationBranch} (pulls latest first)`);
134
+ log.info(` 6. ${cyan("vibeops task done")} — push + open MR/PR; merge on ${git.host} (CI deploys)`);
135
+ }
17
136
  export async function initCommand(options = {}) {
18
137
  const dryRun = options.dryRun === true;
19
- const force = options.force === true;
20
138
  const projectRoot = resolve(options.cwd ?? process.cwd());
21
- const interactive = !dryRun && process.stdin.isTTY === true && options.git === undefined;
22
- const name = deriveProjectName(projectRoot, options.name);
23
- const config = buildConfig(name);
139
+ const interactive = !dryRun &&
140
+ process.stdin.isTTY === true &&
141
+ options.clients === undefined &&
142
+ options.gitPolicy === undefined;
143
+ const proceed = await confirmReinitIfNeeded(projectRoot, options);
144
+ if (!proceed)
145
+ return;
146
+ const existingConfig = await readConfig(projectRoot);
147
+ const reinit = existingConfig !== null || (await isVibeopsProject(projectRoot));
148
+ const force = options.force === true || reinit;
149
+ const clients = await resolveClients(options, interactive);
150
+ if (clients === null)
151
+ return;
152
+ const branchPolicy = await resolveBranchPolicy(options, interactive);
153
+ if (branchPolicy === null)
154
+ return;
155
+ const hostFromFlag = parseGitHostArg(options.gitHost);
156
+ if (options.gitHost !== undefined && hostFromFlag === null) {
157
+ log.error("Invalid --git-host. Use: github or gitlab");
158
+ process.exitCode = 1;
159
+ return;
160
+ }
161
+ let gitConfig = {
162
+ remote: "origin",
163
+ host: hostFromFlag ?? existingConfig?.git?.host ?? "github",
164
+ integrationBranch: branchPolicy.integrationBranch,
165
+ productionBranch: branchPolicy.productionBranch,
166
+ };
167
+ const name = options.name?.trim() ||
168
+ existingConfig?.name ||
169
+ deriveProjectName(projectRoot, options.name);
170
+ const config = buildConfig(name, clients, gitConfig, existingConfig);
24
171
  log.step(`vibeops init ${dryRun ? "(dry-run) " : ""}→ ${projectRoot}`);
25
172
  log.info(` project: ${name}`);
26
173
  log.info(` vibeops: ${config.vibeopsVersion}`);
27
- if (force)
28
- log.warn("--force is on — existing files will be overwritten.");
174
+ log.info(` clients: ${formatClientsList(clients)}`);
175
+ log.info(` git: ${formatGitPolicySummary(gitConfig)}`);
176
+ if (force && reinit)
177
+ log.warn("Template files will be overwritten where they already exist.");
29
178
  log.blank();
30
- const report = await install({ projectRoot, config, dryRun, force });
179
+ const report = await install({
180
+ projectRoot,
181
+ config,
182
+ clients,
183
+ dryRun,
184
+ force,
185
+ });
31
186
  printReport(report, dryRun);
32
- const gitPlan = await resolveGitPlan(options, interactive);
33
- await runGitSetup(projectRoot, gitPlan, dryRun);
34
- if (!dryRun) {
35
- log.blank();
36
- log.info("Next steps:");
37
- log.info(" 1. Read AGENTS.md and `.cursor/rules/`.");
38
- log.info(" 2. Run `vibeops task add` to create the first TASK.");
39
- log.info(" 3. Plan in Cursor Ask (@docs/tasks/TASK-001-….md), then implement.");
40
- }
41
- }
42
- async function resolveGitPlan(options, interactive) {
43
- const defaultBranch = typeof options.defaultBranch === "string" && options.defaultBranch.trim().length > 0
44
- ? options.defaultBranch.trim()
45
- : DEFAULT_BRANCH;
46
187
  const commitMessage = typeof options.commitMessage === "string" && options.commitMessage.trim().length > 0
47
188
  ? options.commitMessage.trim()
48
189
  : DEFAULT_COMMIT_MESSAGE;
49
- if (interactive) {
190
+ let shouldInitialCommit = options.initialCommit !== false &&
191
+ (options.initialCommit === true || options.git === true);
192
+ let finalCommitMessage = commitMessage;
193
+ if (interactive && !dryRun) {
50
194
  log.blank();
51
- log.info("Git setup");
52
- const shouldInitGit = await askYesNo({
53
- message: "Initialize Git repository?",
54
- nonInteractive: false,
55
- defaultValue: true,
56
- });
57
- if (!shouldInitGit) {
58
- return {
59
- shouldInitGit: false,
60
- shouldSetDefaultBranch: false,
61
- defaultBranch,
62
- shouldInitialCommit: false,
63
- commitMessage,
64
- };
65
- }
66
- const useMain = await askYesNo({
67
- message: "Use `main` as default branch?",
68
- nonInteractive: false,
69
- defaultValue: true,
70
- });
71
- const shouldInitialCommit = await askYesNo({
195
+ log.info("Git repository");
196
+ shouldInitialCommit = await askYesNo({
72
197
  message: "Create initial commit?",
73
198
  nonInteractive: false,
74
199
  defaultValue: true,
75
200
  });
76
- const finalMessage = shouldInitialCommit
77
- ? await askInput({
201
+ if (shouldInitialCommit) {
202
+ finalCommitMessage = await askInput({
78
203
  message: "Initial commit message",
79
204
  nonInteractive: false,
80
205
  default: commitMessage,
81
206
  required: true,
82
- })
83
- : commitMessage;
84
- return {
85
- shouldInitGit: true,
86
- shouldSetDefaultBranch: useMain,
87
- defaultBranch,
88
- shouldInitialCommit,
89
- commitMessage: finalMessage.length > 0 ? finalMessage : commitMessage,
90
- };
207
+ });
208
+ }
91
209
  }
92
- const shouldInitGit = options.git !== false && (options.git === true || options.initialCommit === true);
93
- if (!shouldInitGit) {
94
- return {
95
- shouldInitGit: false,
96
- shouldSetDefaultBranch: false,
97
- defaultBranch,
98
- shouldInitialCommit: false,
99
- commitMessage,
210
+ else if (!dryRun && !shouldInitialCommit && options.initialCommit === undefined) {
211
+ shouldInitialCommit = true;
212
+ }
213
+ await runGitSetup(projectRoot, {
214
+ productionBranch: gitConfig.productionBranch,
215
+ integrationBranch: gitConfig.integrationBranch,
216
+ shouldInitialCommit,
217
+ commitMessage: finalCommitMessage,
218
+ }, dryRun);
219
+ const hadRemoteBefore = (await gitRemoteUrl(projectRoot, gitConfig.remote)) !== null;
220
+ const remote = await ensureOriginRemote({
221
+ cwd: projectRoot,
222
+ remoteName: gitConfig.remote,
223
+ dryRun,
224
+ nonInteractive: !interactive,
225
+ allowMissing: options.allowNoRemote === true,
226
+ defaultBranch: gitConfig.productionBranch,
227
+ });
228
+ if (remote !== null) {
229
+ gitConfig = {
230
+ ...gitConfig,
231
+ remote: remote.remote,
232
+ host: remote.host,
100
233
  };
101
234
  }
102
- return {
103
- shouldInitGit: true,
104
- shouldSetDefaultBranch: true,
105
- defaultBranch,
106
- shouldInitialCommit: options.initialCommit !== false,
107
- commitMessage,
108
- };
235
+ // If we created/configured a new remote during init, push baseline branches so
236
+ // `task add` can always pull latest integration before branching.
237
+ if (!dryRun && options.allowNoRemote !== true && !hadRemoteBefore) {
238
+ const remoteName = gitConfig.remote;
239
+ log.blank();
240
+ log.info("Remote bootstrap:");
241
+ try {
242
+ // Ensure local branches are up-to-date with origin if they already exist remotely.
243
+ const prodRemoteExists = await gitRemoteBranchExists(projectRoot, remoteName, gitConfig.productionBranch);
244
+ if (prodRemoteExists) {
245
+ await gitCheckout(projectRoot, gitConfig.productionBranch);
246
+ await gitPullFastForwardOnly(projectRoot, remoteName, gitConfig.productionBranch);
247
+ }
248
+ await gitCheckout(projectRoot, gitConfig.productionBranch);
249
+ await gitPush(projectRoot, remoteName, gitConfig.productionBranch, true);
250
+ log.ok(`pushed ${gitConfig.productionBranch} → ${remoteName}`);
251
+ if (gitConfig.integrationBranch !== gitConfig.productionBranch) {
252
+ const intRemoteExists = await gitRemoteBranchExists(projectRoot, remoteName, gitConfig.integrationBranch);
253
+ if (intRemoteExists) {
254
+ await gitCheckout(projectRoot, gitConfig.integrationBranch);
255
+ await gitPullFastForwardOnly(projectRoot, remoteName, gitConfig.integrationBranch);
256
+ }
257
+ await gitCheckout(projectRoot, gitConfig.integrationBranch);
258
+ await gitPush(projectRoot, remoteName, gitConfig.integrationBranch, true);
259
+ log.ok(`pushed ${gitConfig.integrationBranch} → ${remoteName}`);
260
+ }
261
+ }
262
+ catch (e) {
263
+ const msg = e instanceof Error ? e.message : String(e);
264
+ log.warn(`Automatic push failed: ${msg}`);
265
+ log.info(dim("Run these manually:"));
266
+ log.info(dim(` git push -u ${remoteName} ${gitConfig.productionBranch}`));
267
+ if (gitConfig.integrationBranch !== gitConfig.productionBranch) {
268
+ log.info(dim(` git push -u ${remoteName} ${gitConfig.integrationBranch}`));
269
+ }
270
+ }
271
+ }
272
+ if (!dryRun) {
273
+ await writeConfig(projectRoot, buildConfig(name, clients, gitConfig, existingConfig));
274
+ if (options.allowNoRemote === true && remote === null) {
275
+ log.warn("No git remote configured (--allow-no-remote). task done push/MR will fail until origin exists.");
276
+ }
277
+ printNextSteps(clients, gitConfig);
278
+ }
109
279
  }
110
280
  async function runGitSetup(projectRoot, plan, dryRun) {
111
281
  log.blank();
112
282
  log.info("Git setup:");
113
- if (!plan.shouldInitGit) {
114
- log.info(` ${dim("skipped")} Git initialization`);
115
- if (dryRun) {
116
- log.info(` ${dim("hint")} use ${cyan("vibeops init --git --initial-commit")} to include Git setup in non-interactive mode`);
117
- }
118
- return;
119
- }
120
283
  const alreadyRepo = await isGitRepository(projectRoot);
121
284
  const hasCommitsBefore = alreadyRepo ? await hasAnyCommit(projectRoot) : false;
122
285
  if (dryRun) {
123
286
  log.info(` ${alreadyRepo ? dim("would skip") : green("would run")} git init`);
124
- if (plan.shouldSetDefaultBranch) {
125
- log.info(` ${green("would set")} default branch ${cyan(plan.defaultBranch)}`);
287
+ log.info(` ${green("would set")} production branch ${cyan(plan.productionBranch)}`);
288
+ if (plan.integrationBranch !== plan.productionBranch) {
289
+ log.info(` ${green("would create")} integration branch ${cyan(plan.integrationBranch)}`);
126
290
  }
127
291
  if (plan.shouldInitialCommit) {
128
- log.info(` ${green("would run")} git add .`);
129
- log.info(` ${green("would run")} git commit -m ${JSON.stringify(plan.commitMessage)}`);
130
- }
131
- else {
132
- log.info(` ${dim("would skip")} initial commit`);
292
+ log.info(` ${green("would run")} git add . && git commit`);
133
293
  }
134
- log.info(` ${dim("dry-run")} no git commands executed`);
294
+ log.info(` ${dim("would require")} remote origin (unless --allow-no-remote)`);
135
295
  return;
136
296
  }
137
297
  if (alreadyRepo) {
@@ -141,39 +301,44 @@ async function runGitSetup(projectRoot, plan, dryRun) {
141
301
  await gitInit(projectRoot);
142
302
  log.ok("git init");
143
303
  }
144
- if (plan.shouldSetDefaultBranch) {
145
- const hasCommitsNow = await hasAnyCommit(projectRoot);
146
- if (alreadyRepo && hasCommitsNow) {
147
- log.info(` ${yellow("skipped")} default branch change (repository already has commits)`);
304
+ const hasCommitsNow = await hasAnyCommit(projectRoot);
305
+ if (!hasCommitsNow) {
306
+ await gitSetDefaultBranch(projectRoot, plan.productionBranch);
307
+ log.ok(`production branch ${plan.productionBranch}`);
308
+ }
309
+ else {
310
+ log.info(` ${yellow("skipped")} default branch change (repository already has commits)`);
311
+ }
312
+ if (plan.shouldInitialCommit && !hasCommitsBefore && !hasCommitsNow) {
313
+ const changedFiles = await gitStatusPorcelain(projectRoot);
314
+ if (changedFiles.length > 0) {
315
+ await gitAddAll(projectRoot);
316
+ await gitCommit(projectRoot, plan.commitMessage);
317
+ log.ok(`initial commit on ${plan.productionBranch}`);
148
318
  }
149
319
  else {
150
- await gitSetDefaultBranch(projectRoot, plan.defaultBranch);
151
- log.ok(`default branch ${plan.defaultBranch}`);
320
+ log.info(` ${dim("skipped")} initial commit (nothing to commit)`);
152
321
  }
153
322
  }
154
- if (!plan.shouldInitialCommit) {
155
- log.info(` ${dim("skipped")} initial commit`);
156
- return;
323
+ else if (hasCommitsNow || hasCommitsBefore) {
324
+ log.info(` ${dim("skipped")} initial commit (repository already has commits)`);
157
325
  }
158
- if (hasCommitsBefore || (await hasAnyCommit(projectRoot))) {
159
- log.info(` ${yellow("skipped")} initial commit (repository already has commits)`);
326
+ await ensureIntegrationBranch(projectRoot, plan);
327
+ }
328
+ /** Create integration branch when missing (including re-init on existing repos). */
329
+ async function ensureIntegrationBranch(projectRoot, plan) {
330
+ if (plan.integrationBranch === plan.productionBranch)
160
331
  return;
161
- }
162
- const changedFiles = await gitStatusPorcelain(projectRoot);
163
- log.info(` ${dim("initial commit files")} ${changedFiles.length} file${changedFiles.length === 1 ? "" : "s"} will be included`);
164
- if (changedFiles.length === 0) {
165
- log.info(` ${dim("skipped")} initial commit (nothing to commit)`);
332
+ if (await gitBranchExists(projectRoot, plan.integrationBranch))
166
333
  return;
167
- }
168
- try {
169
- await gitAddAll(projectRoot);
170
- await gitCommit(projectRoot, plan.commitMessage);
171
- const branch = await currentBranchOrUnborn(projectRoot);
172
- log.ok(`initial commit created${branch.branch !== null ? ` on ${branch.branch}` : ""}`);
173
- }
174
- catch (err) {
175
- log.error(`initial commit failed: ${err.message}`);
176
- log.info(` ${dim("hint")} check Git user.name/user.email, then run ${cyan(`git commit -m ${JSON.stringify(plan.commitMessage)}`)} manually.`);
177
- process.exitCode = 1;
334
+ if (!(await hasAnyCommit(projectRoot)))
335
+ return;
336
+ const start = (await gitBranchExists(projectRoot, plan.productionBranch))
337
+ ? plan.productionBranch
338
+ : "HEAD";
339
+ await gitCreateBranch(projectRoot, plan.integrationBranch, start);
340
+ log.ok(`integration branch ${plan.integrationBranch}`);
341
+ if (await gitBranchExists(projectRoot, plan.productionBranch)) {
342
+ await gitCheckout(projectRoot, plan.productionBranch);
178
343
  }
179
344
  }
@@ -0,0 +1,177 @@
1
+ import { resolve } from "node:path";
2
+ import { password, select } from "@inquirer/prompts";
3
+ import { readConfig, setLlmProviderPreference } from "../lib/config.js";
4
+ import { configureOpenAiKey, guideCodexOAuth, printConnectSetupHint, runCursorAgentLogin, } from "../lib/llm-connect.js";
5
+ import { getLlmPreferenceFromConfig, labelLlmProvider, parseLlmProviderPreference, probeAllLlmProviders, resolveLlmProviderForUse, } from "../lib/llm-preference.js";
6
+ import { buildLlmStatusReport, formatLlmStatusLine } from "../lib/llm-status.js";
7
+ import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
8
+ async function requireProject(cwd) {
9
+ const c = await readConfig(cwd);
10
+ if (c === null) {
11
+ log.error("Not a VibeOps project — run vibeops init first.");
12
+ process.exitCode = 1;
13
+ return false;
14
+ }
15
+ return true;
16
+ }
17
+ async function printLlmStatusHuman(cwd) {
18
+ const report = await buildLlmStatusReport(cwd);
19
+ const { preferenceLabel, activeLabel } = formatLlmStatusLine(report);
20
+ log.blank();
21
+ log.info(bold(" LLM"));
22
+ log.info(` ${dim("Preferred")} ${preferenceLabel}`);
23
+ log.info(` ${dim("Active")} ${report.active !== null ? green(activeLabel) : yellow(activeLabel)}`);
24
+ for (const p of report.providers) {
25
+ const mark = p.ok ? green("ok") : dim("—");
26
+ log.info(` ${dim(p.id.padEnd(14))} ${mark} ${dim(p.summary)}`);
27
+ }
28
+ if (!report.anyAvailable) {
29
+ log.info(` ${dim("Hint")} Run ${cyan("vibeops llm connect")}`);
30
+ }
31
+ log.blank();
32
+ }
33
+ export async function llmStatusCommand(opts = {}) {
34
+ const cwd = resolve(opts.cwd ?? process.cwd());
35
+ if (!(await requireProject(cwd)))
36
+ return;
37
+ if (opts.json) {
38
+ const report = await buildLlmStatusReport(cwd);
39
+ log.raw(JSON.stringify(report, null, 2));
40
+ return;
41
+ }
42
+ await printLlmStatusHuman(cwd);
43
+ }
44
+ export async function llmUseCommand(providerArg, opts = {}) {
45
+ const cwd = resolve(opts.cwd ?? process.cwd());
46
+ if (!(await requireProject(cwd)))
47
+ return;
48
+ const nonInteractive = opts.nonInteractive === true || process.stdin.isTTY !== true;
49
+ let preference = null;
50
+ if (providerArg !== undefined) {
51
+ preference = parseLlmProviderPreference(providerArg);
52
+ if (preference === null) {
53
+ log.error(`Unknown provider "${providerArg}". Use auto, codex-oauth, cursor-agent, or openai.`);
54
+ process.exitCode = 1;
55
+ return;
56
+ }
57
+ }
58
+ else if (nonInteractive) {
59
+ log.error("Provider required in non-interactive mode: vibeops llm use <provider>");
60
+ process.exitCode = 1;
61
+ return;
62
+ }
63
+ else {
64
+ const probes = await probeAllLlmProviders(cwd);
65
+ const choices = [
66
+ { name: labelLlmProvider("auto"), value: "auto" },
67
+ ...probes.map((p) => ({
68
+ name: `${labelLlmProvider(p.id)}${p.ok ? "" : " (not connected)"}`,
69
+ value: p.id,
70
+ disabled: p.ok ? undefined : "Run vibeops llm connect first",
71
+ })),
72
+ ];
73
+ preference = await select({
74
+ message: "Default LLM for task add / task done",
75
+ choices,
76
+ loop: false,
77
+ });
78
+ }
79
+ await setLlmProviderPreference(cwd, preference);
80
+ log.ok(`LLM preference → ${labelLlmProvider(preference)}`);
81
+ const active = await resolveLlmProviderForUse(cwd, preference);
82
+ if (active === null && preference !== "auto") {
83
+ log.warn(`"${preference}" is not connected yet. Run ${cyan("vibeops llm connect")}.`);
84
+ printConnectSetupHint(preference);
85
+ process.exitCode = 1;
86
+ }
87
+ else if (active !== null) {
88
+ log.info(` Active now: ${green(labelLlmProvider(active))}`);
89
+ }
90
+ }
91
+ export async function llmConnectCommand(opts = {}) {
92
+ const cwd = resolve(opts.cwd ?? process.cwd());
93
+ if (!(await requireProject(cwd)))
94
+ return;
95
+ const nonInteractive = opts.nonInteractive === true || process.stdin.isTTY !== true;
96
+ if (nonInteractive) {
97
+ log.error("llm connect requires an interactive terminal.");
98
+ process.exitCode = 1;
99
+ return;
100
+ }
101
+ log.info(bold("vibeops llm connect"));
102
+ log.info(dim("Set up providers, then pick which one task add / task done use."));
103
+ log.blank();
104
+ let done = false;
105
+ while (!done) {
106
+ const probes = await probeAllLlmProviders(cwd);
107
+ const config = await readConfig(cwd);
108
+ const pref = getLlmPreferenceFromConfig(config);
109
+ const active = await resolveLlmProviderForUse(cwd, pref);
110
+ log.info(bold(" Connected providers"));
111
+ for (const p of probes) {
112
+ log.info(` ${p.ok ? green("✓") : dim("·")} ${p.id} — ${dim(p.summary)}`);
113
+ }
114
+ log.info(` ${dim("Preferred")} ${labelLlmProvider(pref)} · ${dim("Active")} ${active ? labelLlmProvider(active) : yellow("none")}`);
115
+ log.blank();
116
+ const action = await select({
117
+ message: "LLM setup",
118
+ choices: [
119
+ { name: "Choose default provider (task add / task done)", value: "pick" },
120
+ { name: "Set up Cursor Agent CLI (`agent login`)", value: "setup-cursor" },
121
+ { name: "Show Codex OAuth setup (`codex login`)", value: "setup-codex" },
122
+ { name: "Set up OpenAI API key (.vibeops.env)", value: "setup-openai" },
123
+ { name: "Done", value: "done" },
124
+ ],
125
+ loop: false,
126
+ });
127
+ switch (action) {
128
+ case "pick": {
129
+ await llmUseCommand(undefined, { cwd });
130
+ break;
131
+ }
132
+ case "setup-cursor": {
133
+ const result = await runCursorAgentLogin(cwd);
134
+ if (result.ok)
135
+ log.ok(result.message);
136
+ else {
137
+ log.warn(result.message);
138
+ process.exitCode = 1;
139
+ }
140
+ log.blank();
141
+ break;
142
+ }
143
+ case "setup-codex": {
144
+ const result = await guideCodexOAuth();
145
+ if (result.ok)
146
+ log.ok(result.message);
147
+ else {
148
+ for (const line of result.message.split("\n"))
149
+ log.info(line);
150
+ }
151
+ log.blank();
152
+ break;
153
+ }
154
+ case "setup-openai": {
155
+ const key = await password({
156
+ message: "OpenAI API key (stored in .vibeops.env, gitignored)",
157
+ mask: "*",
158
+ validate: (v) => (v.trim().length > 0 ? true : "Key is required"),
159
+ });
160
+ const result = await configureOpenAiKey(cwd, key);
161
+ if (result.ok)
162
+ log.ok(result.message);
163
+ else
164
+ log.warn(result.message);
165
+ log.blank();
166
+ break;
167
+ }
168
+ case "done":
169
+ done = true;
170
+ break;
171
+ }
172
+ }
173
+ const report = await buildLlmStatusReport(cwd);
174
+ const { activeLabel } = formatLlmStatusLine(report);
175
+ log.info(`Saved preference: ${labelLlmProvider(report.preference)} · Active: ${activeLabel}`);
176
+ log.info(`Check anytime: ${cyan("vibeops llm status")} or ${cyan("vibeops status")}`);
177
+ }