@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,7 +1,9 @@
1
1
  import { resolve } from "node:path";
2
- import { readConfig } from "../lib/config.js";
2
+ import { readConfig, getClientsFromConfig } from "../lib/config.js";
3
+ import { formatGitPolicySummary } from "../lib/git-policy.js";
3
4
  import { readText } from "../lib/filesystem.js";
4
5
  import { readGitInfo } from "../lib/git.js";
6
+ import { buildLlmStatusReport } from "../lib/llm-status.js";
5
7
  import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
6
8
  import { projectPaths } from "../lib/paths.js";
7
9
  import { VERSION } from "../version.js";
@@ -29,6 +31,7 @@ async function buildReport(cwd) {
29
31
  let taskBranch = null;
30
32
  let branchExists = null;
31
33
  let goalExcerpt = "";
34
+ let mergeRequestUrl = null;
32
35
  if (focus !== null) {
33
36
  const body = await readText(focus.filePath);
34
37
  goalExcerpt = summarizeGoal(body);
@@ -36,6 +39,7 @@ async function buildReport(cwd) {
36
39
  testFilled = hasNonEmptySection(body, "Test Result");
37
40
  const ctx = await readGitContext(focus.filePath);
38
41
  taskBranch = ctx?.taskBranch ?? null;
42
+ mergeRequestUrl = ctx?.mergeRequestUrl ?? null;
39
43
  onTaskBranch = isOnTaskBranch(git.branch, ctx);
40
44
  branchExists = await taskBranchExistsFor(cwd, focus);
41
45
  focusBlock = {
@@ -49,6 +53,7 @@ async function buildReport(cwd) {
49
53
  onTaskBranch,
50
54
  taskBranch,
51
55
  taskBranchExists: branchExists,
56
+ mergeRequestUrl,
52
57
  };
53
58
  }
54
59
  const hint = computeNextHint({
@@ -58,14 +63,17 @@ async function buildReport(cwd) {
58
63
  testFilled,
59
64
  onTaskBranch,
60
65
  });
66
+ const llm = isVibeopsProject ? await buildLlmStatusReport(cwd) : null;
61
67
  return {
62
68
  vibeopsVersion: VERSION,
63
69
  isVibeopsProject,
64
70
  projectName: config?.name ?? null,
71
+ clients: getClientsFromConfig(config),
65
72
  git: {
66
73
  isRepo: git.isRepo,
67
74
  branch: git.branch,
68
75
  dirty: git.dirty,
76
+ policy: config?.git ? formatGitPolicySummary(config.git) : null,
69
77
  },
70
78
  counts,
71
79
  inProgress: inProgress.map((t) => ({
@@ -75,6 +83,7 @@ async function buildReport(cwd) {
75
83
  })),
76
84
  focus: focusBlock,
77
85
  nextHint: hintToText(hint, cwd, focus),
86
+ llm,
78
87
  };
79
88
  }
80
89
  function printHuman(report) {
@@ -90,6 +99,9 @@ function printHuman(report) {
90
99
  return;
91
100
  }
92
101
  log.info(` ${dim("Project")} ${report.projectName ?? "(unnamed)"}`);
102
+ if (report.clients.length > 0) {
103
+ log.info(` ${dim("Clients")} ${report.clients.join(", ")}`);
104
+ }
93
105
  if (report.counts) {
94
106
  const c = report.counts;
95
107
  log.info(` ${dim("Tasks")} ${c.total} total · ${cyan(String(c.in_progress))} in progress · ${c.done} done`);
@@ -125,6 +137,9 @@ function printHuman(report) {
125
137
  log.info(` ${dim("Tree")} ${yellow("dirty")}`);
126
138
  else if (report.git.dirty === false)
127
139
  log.info(` ${dim("Tree")} ${green("clean")}`);
140
+ if (report.git.policy) {
141
+ log.info(` ${dim("Policy")} ${report.git.policy}`);
142
+ }
128
143
  if (report.focus?.taskBranch) {
129
144
  const on = report.focus.onTaskBranch === true
130
145
  ? green("yes")
@@ -133,6 +148,27 @@ function printHuman(report) {
133
148
  : dim("—");
134
149
  log.info(` ${dim("Task br.")} ${report.focus.taskBranch} · on branch: ${on}`);
135
150
  }
151
+ if (report.focus?.mergeRequestUrl) {
152
+ log.info(` ${dim("MR/PR")} ${report.focus.mergeRequestUrl}`);
153
+ }
154
+ }
155
+ if (report.llm) {
156
+ log.blank();
157
+ log.info(bold(" LLM"));
158
+ log.info(` ${dim("Preferred")} ${report.llm.preference}`);
159
+ const active = report.llm.active !== null
160
+ ? green(`${report.llm.active} (ok)`)
161
+ : report.llm.anyAvailable
162
+ ? yellow("(preference not available)")
163
+ : yellow("none");
164
+ log.info(` ${dim("Active")} ${active}`);
165
+ for (const p of report.llm.providers) {
166
+ const summary = p.summary.length > 56 ? `${p.summary.slice(0, 53)}…` : p.summary;
167
+ log.info(` ${dim(p.id.padEnd(14))} ${p.ok ? green("ok") : dim("—")} ${dim(summary)}`);
168
+ }
169
+ if (!report.llm.anyAvailable) {
170
+ log.info(` ${dim("Setup")} ${cyan("vibeops llm connect")}`);
171
+ }
136
172
  }
137
173
  log.blank();
138
174
  log.info(`${dim("Next")} ${report.nextHint}`);
@@ -1,21 +1,39 @@
1
1
  import { resolve } from "node:path";
2
2
  import { pathExists, writeText } from "../lib/filesystem.js";
3
+ import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
3
4
  import { askInput } from "../lib/inquirer-helpers.js";
4
5
  import { bold, cyan, dim, log, yellow } from "../lib/logger.js";
5
6
  import { projectPaths } from "../lib/paths.js";
6
- import { readConfig } from "../lib/config.js";
7
7
  import { slugify } from "../lib/slug.js";
8
8
  import { fallbackTaskDraft, llmScaffoldTask } from "../lib/task-add-llm.js";
9
9
  import { allocateNextTaskNumber, formatTaskId, uniqueTaskPath, } from "../lib/task-scaffold.js";
10
10
  import { findBlockingTask, relPath } from "../lib/task-context.js";
11
11
  import { startTaskBranch } from "../lib/task-start.js";
12
12
  import { loadActionableTasks, readGitContext } from "../lib/task.js";
13
+ async function loadGitConfigOrNull(root, dryRun) {
14
+ try {
15
+ return await requireGitConfig(root);
16
+ }
17
+ catch (e) {
18
+ if (e instanceof GitConfigError) {
19
+ if (dryRun)
20
+ return null;
21
+ log.error(e.message);
22
+ process.exitCode = 1;
23
+ return null;
24
+ }
25
+ throw e;
26
+ }
27
+ }
13
28
  export async function taskAddCommand(opts = {}) {
14
29
  const root = resolve(opts.cwd ?? process.cwd());
15
30
  const dryRun = opts.dryRun === true;
16
31
  const nonInteractive = opts.nonInteractive === true || dryRun || process.stdin.isTTY !== true;
17
32
  log.info(bold("vibeops task add"));
18
33
  log.blank();
34
+ const gitCfg = await loadGitConfigOrNull(root, dryRun);
35
+ if (!dryRun && gitCfg === null)
36
+ return;
19
37
  const paths = projectPaths(root);
20
38
  const blocking = await findBlockingTask(paths, root);
21
39
  if (blocking !== null) {
@@ -59,6 +77,7 @@ export async function taskAddCommand(opts = {}) {
59
77
  }
60
78
  else {
61
79
  log.warn("LLM unavailable — using minimal TASK template.");
80
+ log.info(dim(` Run ${cyan("vibeops llm connect")} to set up Codex, Cursor Agent CLI, or OpenAI.`));
62
81
  const fb = fallbackTaskDraft(taskId, idea);
63
82
  title = fb.title;
64
83
  slug = fb.slug;
@@ -67,9 +86,19 @@ export async function taskAddCommand(opts = {}) {
67
86
  }
68
87
  const { filePath } = uniqueTaskPath(paths.docsTasks, taskId, slugify(slug || title), tasks.map((t) => t.filePath));
69
88
  const relFile = relPath(root, filePath);
89
+ const integration = gitCfg?.integrationBranch ?? "integration";
70
90
  if (dryRun) {
71
91
  log.info(`[dry-run] Would create ${bold(taskId)} → ${cyan(relFile)}`);
72
- log.info(dim(` branch task/${slugify(slug || title).replace(/^(\d+)-/, "$1-")}`));
92
+ log.info(dim(` branch task/${slugify(slug || title).replace(/^(\d+)-/, "$1-")} from ${integration}`));
93
+ if (gitCfg) {
94
+ await startTaskBranch({
95
+ cwd: root,
96
+ taskFile: filePath,
97
+ integrationBranch: gitCfg.integrationBranch,
98
+ remote: gitCfg.remote,
99
+ dryRun: true,
100
+ });
101
+ }
73
102
  return;
74
103
  }
75
104
  if (await pathExists(filePath)) {
@@ -77,15 +106,13 @@ export async function taskAddCommand(opts = {}) {
77
106
  }
78
107
  await writeText(filePath, markdown);
79
108
  log.ok(`Created ${bold(taskId)} → ${cyan(relFile)}`);
80
- const config = await readConfig(root);
81
- if (config === null) {
82
- log.warn("No .vibeops.json — run vibeops init for full template.");
83
- }
84
109
  log.blank();
85
110
  log.step("Starting task branch…");
86
111
  const started = await startTaskBranch({
87
112
  cwd: root,
88
113
  taskFile: filePath,
114
+ integrationBranch: gitCfg.integrationBranch,
115
+ remote: gitCfg.remote,
89
116
  allowDirty: true,
90
117
  });
91
118
  if (!started) {
@@ -1,18 +1,21 @@
1
1
  import { resolve } from "node:path";
2
- import { askYesNo } from "../lib/inquirer-helpers.js";
3
2
  import { readText } from "../lib/filesystem.js";
4
3
  import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
5
4
  import { projectPaths } from "../lib/paths.js";
6
5
  import { resolveTask, taskNotFoundMessage } from "../lib/resolve-task.js";
7
- import { mergeTaskBranch } from "../lib/task-merge.js";
8
6
  import { applyTaskDoneMemory, fallbackResultSections, llmCompleteTaskDone, writeTaskResultSections, } from "../lib/task-done-llm.js";
9
7
  import { relPath } from "../lib/task-context.js";
10
- import { gitAddPaths, listWorkingTreeRelPaths, partitionPathsForAutoCommit, readGitInfo, runGit, } from "../lib/git.js";
8
+ import { requireGitConfig } from "../lib/git-config.js";
9
+ import { gitAddPaths, gitPush, listWorkingTreeRelPaths, partitionPathsForAutoCommit, readGitInfo, runGit, } from "../lib/git.js";
11
10
  import { hasNonEmptySection, markGitContextDone, parseTaskFilename, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
11
+ import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
12
12
  function commitMessageFor(taskId, title) {
13
13
  const slug = title.replace(/^TASK-\d+\s*[:\-]\s*/i, "").trim() || taskId;
14
14
  return `feat(${taskId.toLowerCase()}): ${slug}`;
15
15
  }
16
+ function closureCommitMessageFor(taskId) {
17
+ return `docs(${taskId.toLowerCase()}): close task metadata`;
18
+ }
16
19
  async function tryCommitDirty(cwd, message, dryRun) {
17
20
  const git = await readGitInfo(cwd);
18
21
  if (!git.isRepo || git.dirty !== true)
@@ -32,11 +35,45 @@ async function tryCommitDirty(cwd, message, dryRun) {
32
35
  log.ok(`Committed: ${message}`);
33
36
  return true;
34
37
  }
38
+ /**
39
+ * After push/MR: write Status Done + Git Context closure, commit, push so the remote
40
+ * branch matches local TASK metadata (canonical two-phase close).
41
+ */
42
+ async function commitClosureMetadataAndPush(opts) {
43
+ if (opts.dryRun) {
44
+ log.info(dim(" would set Status → Done and update Git Context (doneAt)"));
45
+ log.info(dim(" would commit closure metadata"));
46
+ if (opts.push !== null) {
47
+ log.info(dim(` would git push ${opts.push.remote} ${opts.push.branch} (closure commit)`));
48
+ }
49
+ return true;
50
+ }
51
+ await updateInlineStatus(opts.taskFile, "done");
52
+ await markGitContextDone(opts.taskFile);
53
+ log.ok("Status → Done");
54
+ const committed = await tryCommitDirty(opts.cwd, closureCommitMessageFor(opts.taskId), false);
55
+ if (!committed) {
56
+ log.warn("No file changes for closure commit — verify TASK Status and Git Context on the remote branch.");
57
+ return true;
58
+ }
59
+ if (opts.push === null)
60
+ return true;
61
+ try {
62
+ await gitPush(opts.cwd, opts.push.remote, opts.push.branch, false);
63
+ log.ok(`Pushed closure commit → ${opts.push.remote}/${opts.push.branch}`);
64
+ return true;
65
+ }
66
+ catch (e) {
67
+ const msg = e instanceof Error ? e.message : String(e);
68
+ log.error(`Closure commit push failed: ${msg}`);
69
+ log.info(dim(`Fix auth/remote, then: git push ${opts.push.remote} ${opts.push.branch}`));
70
+ return false;
71
+ }
72
+ }
35
73
  export async function taskDoneCommand(taskRef, options = {}) {
36
74
  const cwd = resolve(options.cwd ?? process.cwd());
37
75
  const paths = projectPaths(cwd);
38
76
  const dryRun = options.dryRun === true;
39
- const interactive = !dryRun && process.stdin.isTTY === true && options.yes !== true;
40
77
  const resolved = await resolveTask(paths, cwd, taskRef);
41
78
  if (resolved === null) {
42
79
  log.error(taskNotFoundMessage(taskRef));
@@ -98,43 +135,75 @@ export async function taskDoneCommand(taskRef, options = {}) {
98
135
  if (!resultOk || !testOk) {
99
136
  log.info(" · LLM fill Result / Test Result + patch docs/project/*");
100
137
  }
101
- log.info(" · Status → Done");
102
- log.info(" · commit if dirty, merge task branch (unless --no-merge)");
138
+ log.info(" · commit implementation, push task branch, open MR/PR (unless --no-pr)");
139
+ log.info(" · Status → Done; commit closure metadata; push again");
140
+ await finishTaskWithPullRequest({
141
+ cwd,
142
+ taskFile,
143
+ dryRun: true,
144
+ skipPr: options.noPr === true,
145
+ });
146
+ const gitCtxDry = await readGitContext(taskFile);
147
+ const gitCfgDry = gitCtxDry !== null
148
+ ? await requireGitConfig(cwd).catch(() => null)
149
+ : null;
150
+ await commitClosureMetadataAndPush({
151
+ cwd,
152
+ taskFile,
153
+ taskId,
154
+ dryRun: true,
155
+ push: gitCtxDry !== null && gitCfgDry !== null
156
+ ? { remote: gitCfgDry.remote, branch: gitCtxDry.taskBranch }
157
+ : null,
158
+ });
103
159
  return;
104
160
  }
105
- await updateInlineStatus(taskFile, "done");
106
- await markGitContextDone(taskFile);
107
- log.ok("Status → Done");
108
161
  const parts = parseTaskFilename(taskFile);
109
162
  const commitMsg = commitMessageFor(parts.id, meta.title);
110
163
  await tryCommitDirty(cwd, commitMsg, false);
111
164
  const gitCtx = await readGitContext(taskFile);
112
- if (options.noMerge !== true && gitCtx !== null) {
113
- let doMerge = options.yes === true || !interactive;
114
- if (interactive) {
115
- doMerge = await askYesNo({
116
- message: `Merge into ${gitCtx.baseBranch} and delete ${gitCtx.taskBranch}?`,
117
- nonInteractive: false,
118
- defaultValue: true,
119
- });
165
+ if (gitCtx !== null) {
166
+ const prResult = await finishTaskWithPullRequest({
167
+ cwd,
168
+ taskFile,
169
+ skipPr: options.noPr === true,
170
+ });
171
+ if (!prResult.ok) {
172
+ log.error("TASK left In Progress — fix push/MR, then rerun task done.");
173
+ process.exitCode = 1;
174
+ return;
120
175
  }
121
- if (doMerge) {
122
- const mergeResult = await mergeTaskBranch({
123
- cwd,
124
- taskId: meta.id,
125
- taskTitle: meta.title,
126
- gitCtx,
127
- allowDirty: options.allowDirty,
128
- });
129
- if (!mergeResult.ok)
130
- process.exitCode = 1;
176
+ if (prResult.mergeRequestUrl) {
177
+ log.info(` ${dim("MR/PR")} ${prResult.mergeRequestUrl}`);
178
+ log.info(dim("Merge on GitHub/GitLab — CI deploys after merge."));
131
179
  }
132
- else {
133
- log.info(dim("Merge skipped."));
180
+ }
181
+ else {
182
+ log.info(dim("No Git Context — push/MR skipped."));
183
+ }
184
+ const gitCtxAfter = await readGitContext(taskFile);
185
+ let pushTarget = null;
186
+ if (gitCtxAfter !== null) {
187
+ try {
188
+ const gitCfg = await requireGitConfig(cwd);
189
+ pushTarget = { remote: gitCfg.remote, branch: gitCtxAfter.taskBranch };
190
+ }
191
+ catch (e) {
192
+ if (e instanceof Error)
193
+ log.warn(e.message);
134
194
  }
135
195
  }
136
- else if (gitCtx === null) {
137
- log.info(dim("No Git Context — merge skipped."));
196
+ const closed = await commitClosureMetadataAndPush({
197
+ cwd,
198
+ taskFile,
199
+ taskId: parts.id,
200
+ dryRun: false,
201
+ push: pushTarget,
202
+ });
203
+ if (!closed) {
204
+ log.error("TASK metadata is Done locally but closure push failed — push manually, then continue.");
205
+ process.exitCode = 1;
206
+ return;
138
207
  }
139
208
  log.blank();
140
209
  log.info(`Next: ${cyan("vibeops task add")} or ${cyan("vibeops status")}`);
@@ -1,8 +1,57 @@
1
1
  import { join } from "node:path";
2
- import { readTextOrNull, writeText } from "./filesystem.js";
2
+ import { pathExists, readTextOrNull, writeText } from "./filesystem.js";
3
3
  import { VIBEOPS_CONFIG_FILE } from "./paths.js";
4
4
  import { VERSION } from "../version.js";
5
- import { VIBEOPS_CONFIG_SCHEMA_VERSION } from "../types/config.js";
5
+ import { VIBEOPS_CONFIG_SCHEMA_VERSION, } from "../types/config.js";
6
+ import { isVibeopsClientId } from "./init-clients.js";
7
+ function parseClientsBlock(raw) {
8
+ if (!Array.isArray(raw))
9
+ return undefined;
10
+ const out = [];
11
+ for (const item of raw) {
12
+ if (typeof item === "string" && isVibeopsClientId(item) && !out.includes(item)) {
13
+ out.push(item);
14
+ }
15
+ }
16
+ return out.length > 0 ? out : undefined;
17
+ }
18
+ export function getClientsFromConfig(config) {
19
+ if (config?.clients && config.clients.length > 0)
20
+ return [...config.clients];
21
+ return ["cursor"];
22
+ }
23
+ export async function isVibeopsProject(root) {
24
+ if ((await readConfig(root)) !== null)
25
+ return true;
26
+ return pathExists(join(root, VIBEOPS_CONFIG_FILE));
27
+ }
28
+ function parseGitBlock(raw) {
29
+ if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw)) {
30
+ return undefined;
31
+ }
32
+ const o = raw;
33
+ const remote = typeof o.remote === "string" && o.remote.length > 0 ? o.remote : "origin";
34
+ const host = o.host === "github" || o.host === "gitlab" ? o.host : undefined;
35
+ const integrationBranch = typeof o.integrationBranch === "string" && o.integrationBranch.length > 0
36
+ ? o.integrationBranch
37
+ : undefined;
38
+ const productionBranch = typeof o.productionBranch === "string" && o.productionBranch.length > 0
39
+ ? o.productionBranch
40
+ : undefined;
41
+ if (!host || !integrationBranch || !productionBranch)
42
+ return undefined;
43
+ return { remote, host, integrationBranch, productionBranch };
44
+ }
45
+ function parseLlmBlock(raw) {
46
+ if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw)) {
47
+ return undefined;
48
+ }
49
+ const p = raw.provider;
50
+ if (p === "auto" || p === "codex-oauth" || p === "cursor-agent" || p === "openai") {
51
+ return { provider: p };
52
+ }
53
+ return undefined;
54
+ }
6
55
  export async function readConfig(root) {
7
56
  const text = await readTextOrNull(join(root, VIBEOPS_CONFIG_FILE));
8
57
  if (text === null)
@@ -13,11 +62,17 @@ export async function readConfig(root) {
13
62
  typeof parsed.vibeopsVersion === "string" &&
14
63
  typeof parsed.createdAt === "string" &&
15
64
  parsed.schemaVersion === VIBEOPS_CONFIG_SCHEMA_VERSION) {
65
+ const llm = parseLlmBlock(parsed.llm);
66
+ const git = parseGitBlock(parsed.git);
67
+ const clients = parseClientsBlock(parsed.clients) ?? ["cursor"];
16
68
  return {
17
69
  name: parsed.name,
18
70
  vibeopsVersion: parsed.vibeopsVersion,
19
71
  schemaVersion: VIBEOPS_CONFIG_SCHEMA_VERSION,
20
72
  createdAt: parsed.createdAt,
73
+ clients,
74
+ ...(git ? { git } : {}),
75
+ ...(llm ? { llm } : {}),
21
76
  };
22
77
  }
23
78
  return null;
@@ -26,12 +81,15 @@ export async function readConfig(root) {
26
81
  return null;
27
82
  }
28
83
  }
29
- export function buildConfig(name, nowIso) {
84
+ export function buildConfig(name, clients, git, existing, nowIso) {
30
85
  return {
31
86
  name,
32
87
  vibeopsVersion: VERSION,
33
88
  schemaVersion: VIBEOPS_CONFIG_SCHEMA_VERSION,
34
- createdAt: nowIso ?? new Date().toISOString(),
89
+ createdAt: existing?.createdAt ?? nowIso ?? new Date().toISOString(),
90
+ clients,
91
+ git,
92
+ llm: existing?.llm ?? { provider: "auto" },
35
93
  };
36
94
  }
37
95
  export function configToJson(config) {
@@ -40,3 +98,25 @@ export function configToJson(config) {
40
98
  export async function writeConfig(root, config) {
41
99
  await writeText(join(root, VIBEOPS_CONFIG_FILE), configToJson(config));
42
100
  }
101
+ /**
102
+ * Merge `llm.provider` into existing `.vibeops.json` (preserves other keys).
103
+ */
104
+ export async function setLlmProviderPreference(root, provider) {
105
+ const path = join(root, VIBEOPS_CONFIG_FILE);
106
+ const text = await readTextOrNull(path);
107
+ if (text === null) {
108
+ throw new Error(`${VIBEOPS_CONFIG_FILE} not found — run vibeops init first.`);
109
+ }
110
+ let data;
111
+ try {
112
+ data = JSON.parse(text);
113
+ }
114
+ catch {
115
+ throw new Error(`${VIBEOPS_CONFIG_FILE} is not valid JSON.`);
116
+ }
117
+ const prevLlm = typeof data.llm === "object" && data.llm !== null && !Array.isArray(data.llm)
118
+ ? data.llm
119
+ : {};
120
+ data.llm = { ...prevLlm, provider };
121
+ await writeText(path, `${JSON.stringify(data, null, 2)}\n`);
122
+ }
@@ -0,0 +1,32 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { VIBEOPS_ENV_FILE } from "./paths.js";
4
+ /**
5
+ * Loads `KEY=value` pairs from `.vibeops.env` without overwriting existing `process.env`.
6
+ */
7
+ export async function loadVibeopsEnv(cwd) {
8
+ const path = join(resolve(cwd), VIBEOPS_ENV_FILE);
9
+ try {
10
+ const text = await readFile(path, "utf-8");
11
+ for (const line of text.split("\n")) {
12
+ const t = line.trim();
13
+ if (t.length === 0 || t.startsWith("#"))
14
+ continue;
15
+ const eq = t.indexOf("=");
16
+ if (eq <= 0)
17
+ continue;
18
+ const key = t.slice(0, eq).trim();
19
+ let val = t.slice(eq + 1).trim();
20
+ if ((val.startsWith('"') && val.endsWith('"')) ||
21
+ (val.startsWith("'") && val.endsWith("'"))) {
22
+ val = val.slice(1, -1);
23
+ }
24
+ if (key.length > 0 && process.env[key] === undefined) {
25
+ process.env[key] = val;
26
+ }
27
+ }
28
+ }
29
+ catch {
30
+ /* missing or unreadable — ok */
31
+ }
32
+ }
@@ -0,0 +1,21 @@
1
+ import { readConfig } from "./config.js";
2
+ export class GitConfigError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "GitConfigError";
6
+ }
7
+ }
8
+ /** Load `.vibeops.json` git block or throw with re-init guidance. */
9
+ export async function requireGitConfig(cwd) {
10
+ const config = await readConfig(cwd);
11
+ if (!config) {
12
+ throw new GitConfigError("No .vibeops.json found. Run vibeops init in this project first.");
13
+ }
14
+ if (!config.git?.integrationBranch || !config.git?.productionBranch) {
15
+ throw new GitConfigError("Missing git policy in .vibeops.json. Re-run vibeops init to set integration and production branches.");
16
+ }
17
+ if (!config.git.host || !config.git.remote) {
18
+ throw new GitConfigError("Incomplete git config in .vibeops.json. Re-run vibeops init.");
19
+ }
20
+ return config.git;
21
+ }
@@ -0,0 +1,12 @@
1
+ /** Infer Git host from a remote URL. */
2
+ export function detectGitHost(remoteUrl) {
3
+ const u = remoteUrl.trim().toLowerCase();
4
+ if (u.includes("github.com"))
5
+ return "github";
6
+ if (u.includes("gitlab"))
7
+ return "gitlab";
8
+ return null;
9
+ }
10
+ export function mergeRequestLabel(host) {
11
+ return host === "gitlab" ? "Merge request" : "Pull request";
12
+ }
@@ -0,0 +1,67 @@
1
+ import { select } from "@inquirer/prompts";
2
+ import { askInput } from "./inquirer-helpers.js";
3
+ export const GIT_POLICY_PRESETS = [
4
+ {
5
+ id: "gitflow",
6
+ label: "GitFlow lite — develop (integration) + main (production)",
7
+ integrationBranch: "develop",
8
+ productionBranch: "main",
9
+ },
10
+ {
11
+ id: "trunk",
12
+ label: "Trunk — main only (integration and production = main)",
13
+ integrationBranch: "main",
14
+ productionBranch: "main",
15
+ },
16
+ ];
17
+ export function parseGitPolicyArg(raw) {
18
+ if (raw === undefined || raw.trim().length === 0)
19
+ return null;
20
+ const t = raw.trim().toLowerCase();
21
+ if (t === "gitflow" || t === "develop-main")
22
+ return "gitflow";
23
+ if (t === "trunk" || t === "main")
24
+ return "trunk";
25
+ if (t === "custom")
26
+ return "custom";
27
+ return null;
28
+ }
29
+ export function resolvePreset(id) {
30
+ return GIT_POLICY_PRESETS.find((p) => p.id === id) ?? null;
31
+ }
32
+ export async function askGitPolicy() {
33
+ const preset = await select({
34
+ message: "Branch policy",
35
+ choices: [
36
+ ...GIT_POLICY_PRESETS.map((p) => ({ name: p.label, value: p.id })),
37
+ { name: "Custom branch names", value: "custom" },
38
+ ],
39
+ loop: false,
40
+ });
41
+ if (preset !== "custom") {
42
+ const p = resolvePreset(preset);
43
+ return {
44
+ integrationBranch: p.integrationBranch,
45
+ productionBranch: p.productionBranch,
46
+ };
47
+ }
48
+ const integrationBranch = await askInput({
49
+ message: "Integration branch (task branches start here; MR/PR target)",
50
+ nonInteractive: false,
51
+ default: "develop",
52
+ required: true,
53
+ });
54
+ const productionBranch = await askInput({
55
+ message: "Production branch (releases / deploy; merge manually)",
56
+ nonInteractive: false,
57
+ default: "main",
58
+ required: true,
59
+ });
60
+ return { integrationBranch, productionBranch };
61
+ }
62
+ export function formatGitPolicySummary(git) {
63
+ if (git.integrationBranch === git.productionBranch) {
64
+ return `${git.integrationBranch} (integration + production)`;
65
+ }
66
+ return `integration=${git.integrationBranch}, production=${git.productionBranch}`;
67
+ }