climaybe 3.4.7 → 3.5.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 (35) hide show
  1. package/README.md +82 -31
  2. package/bin/version.txt +1 -1
  3. package/package.json +3 -3
  4. package/src/commands/add-cursor-skill.js +11 -12
  5. package/src/commands/add-store.js +15 -0
  6. package/src/commands/app-init.js +8 -12
  7. package/src/commands/create-entrypoints.js +13 -3
  8. package/src/commands/ensure-branches.js +11 -0
  9. package/src/commands/init.js +114 -17
  10. package/src/commands/update-workflows.js +3 -2
  11. package/src/cursor/rules/00-rule-index.mdc +2 -1
  12. package/src/cursor/rules/cursor-rule-template.mdc +8 -7
  13. package/src/cursor/rules/figma-design-system.mdc +8 -8
  14. package/src/cursor/rules/global-rules-reference.mdc +2 -1
  15. package/src/cursor/rules/performance-guide.mdc +436 -0
  16. package/src/cursor/rules/snippets.mdc +1 -1
  17. package/src/cursor/skills/accessibility-pass/SKILL.md +2 -2
  18. package/src/cursor/skills/changelog-release/SKILL.md +2 -2
  19. package/src/cursor/skills/commit-in-groups/SKILL.md +2 -2
  20. package/src/cursor/skills/linear-create-task/SKILL.md +1 -1
  21. package/src/cursor/skills/liquid-doc-comments/SKILL.md +2 -2
  22. package/src/cursor/skills/locale-translation-prep/SKILL.md +2 -2
  23. package/src/cursor/skills/schema-section-change/SKILL.md +3 -3
  24. package/src/cursor/skills/section-from-spec/SKILL.md +6 -6
  25. package/src/cursor/skills/theme-check-fix/SKILL.md +4 -4
  26. package/src/index.js +3 -3
  27. package/src/lib/branch-protection.js +205 -0
  28. package/src/lib/config.js +11 -1
  29. package/src/lib/cursor-bundle.js +150 -12
  30. package/src/lib/git.js +12 -0
  31. package/src/lib/prompts.js +98 -4
  32. package/src/lib/theme-dev-kit.js +4 -0
  33. package/src/workflows/build/build-pipeline.yml +14 -0
  34. package/src/workflows/multi/pr-to-live.yml +1 -1
  35. package/src/workflows/preview/reusable-comment-on-pr.yml +6 -3
@@ -0,0 +1,205 @@
1
+ import { execSync, spawnSync } from 'node:child_process';
2
+ import pc from 'picocolors';
3
+ import { getGitHubRepoSpec, hasGitHubRemote, isGhAvailable } from './github-secrets.js';
4
+
5
+ export const LIVE_BRANCH_BYPASS_USERS = ['shopify[bot]', 'github-actions[bot]', 'actions-user'];
6
+
7
+ function runGh(args, cwd = process.cwd(), input = null) {
8
+ return spawnSync('gh', args, {
9
+ cwd,
10
+ input: input == null ? undefined : input,
11
+ encoding: 'utf-8',
12
+ stdio: ['pipe', 'pipe', 'pipe'],
13
+ });
14
+ }
15
+
16
+ function remoteBranchExists(branch, cwd = process.cwd()) {
17
+ try {
18
+ execSync(`git ls-remote --exit-code --heads origin ${branch}`, {
19
+ cwd,
20
+ encoding: 'utf-8',
21
+ stdio: 'pipe',
22
+ });
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ function isNotFoundError(result) {
30
+ const text = `${result?.stdout || ''} ${result?.stderr || ''}`;
31
+ return /404|not found|branch not found/i.test(text);
32
+ }
33
+
34
+ export function getBranchProtectionTargets(mode = 'single', aliases = []) {
35
+ const liveBranches = aliases.map((alias) => `live-${alias}`);
36
+ if (mode === 'multi') {
37
+ return {
38
+ protect: liveBranches,
39
+ unprotect: ['main'],
40
+ };
41
+ }
42
+ return {
43
+ protect: ['main'],
44
+ unprotect: liveBranches,
45
+ };
46
+ }
47
+
48
+ export function buildBranchProtectionPayload({ allowShopifyBypass = false } = {}) {
49
+ return {
50
+ required_status_checks: null,
51
+ enforce_admins: true,
52
+ required_pull_request_reviews: {
53
+ dismiss_stale_reviews: false,
54
+ require_code_owner_reviews: false,
55
+ required_approving_review_count: 0,
56
+ require_last_push_approval: false,
57
+ bypass_pull_request_allowances: {
58
+ users: allowShopifyBypass ? LIVE_BRANCH_BYPASS_USERS : [],
59
+ teams: [],
60
+ apps: [],
61
+ },
62
+ },
63
+ restrictions: null,
64
+ required_linear_history: false,
65
+ allow_force_pushes: false,
66
+ allow_deletions: false,
67
+ block_creations: false,
68
+ required_conversation_resolution: true,
69
+ lock_branch: false,
70
+ allow_fork_syncing: true,
71
+ };
72
+ }
73
+
74
+ function protectBranch({ repo, branch, allowShopifyBypass = false, cwd = process.cwd() }) {
75
+ const path = `repos/${repo}/branches/${branch}/protection`;
76
+ const payload = JSON.stringify(buildBranchProtectionPayload({ allowShopifyBypass }));
77
+ const result = runGh(
78
+ [
79
+ 'api',
80
+ '--method',
81
+ 'PUT',
82
+ '-H',
83
+ 'Accept: application/vnd.github+json',
84
+ path,
85
+ '--input',
86
+ '-',
87
+ ],
88
+ cwd,
89
+ payload
90
+ );
91
+ if (result.status !== 0) {
92
+ throw new Error((result.stderr || result.stdout || `gh api failed for ${branch}`).trim());
93
+ }
94
+ }
95
+
96
+ function unprotectBranch({ repo, branch, cwd = process.cwd() }) {
97
+ const path = `repos/${repo}/branches/${branch}/protection`;
98
+ const result = runGh(
99
+ [
100
+ 'api',
101
+ '--method',
102
+ 'DELETE',
103
+ '-H',
104
+ 'Accept: application/vnd.github+json',
105
+ path,
106
+ ],
107
+ cwd
108
+ );
109
+ if (result.status !== 0 && !isNotFoundError(result)) {
110
+ throw new Error((result.stderr || result.stdout || `gh api failed for ${branch}`).trim());
111
+ }
112
+ }
113
+
114
+ export function syncBranchProtection({ mode = 'single', aliases = [], cwd = process.cwd() } = {}) {
115
+ if (!hasGitHubRemote(cwd)) {
116
+ return { skipped: 'no_github_remote', applied: [], removed: [], pending: [], failed: [] };
117
+ }
118
+ if (!isGhAvailable()) {
119
+ return { skipped: 'gh_unavailable', applied: [], removed: [], pending: [], failed: [] };
120
+ }
121
+
122
+ const repo = getGitHubRepoSpec(cwd);
123
+ if (!repo) {
124
+ return { skipped: 'repo_parse_failed', applied: [], removed: [], pending: [], failed: [] };
125
+ }
126
+
127
+ const { protect, unprotect } = getBranchProtectionTargets(mode, aliases);
128
+ const applied = [];
129
+ const removed = [];
130
+ const pending = [];
131
+ const failed = [];
132
+
133
+ for (const branch of unprotect) {
134
+ if (!remoteBranchExists(branch, cwd)) continue;
135
+ try {
136
+ unprotectBranch({ repo, branch, cwd });
137
+ removed.push(branch);
138
+ } catch (err) {
139
+ failed.push({ branch, action: 'unprotect', message: err.message });
140
+ }
141
+ }
142
+
143
+ for (const branch of protect) {
144
+ if (!remoteBranchExists(branch, cwd)) {
145
+ pending.push(branch);
146
+ continue;
147
+ }
148
+ try {
149
+ const isLive = /^live-/.test(branch);
150
+ protectBranch({
151
+ repo,
152
+ branch,
153
+ allowShopifyBypass: isLive,
154
+ cwd,
155
+ });
156
+ applied.push(branch);
157
+ } catch (err) {
158
+ failed.push({ branch, action: 'protect', message: err.message });
159
+ }
160
+ }
161
+
162
+ return { skipped: null, applied, removed, pending, failed };
163
+ }
164
+
165
+ export function logBranchProtectionResult(result, mode = 'single') {
166
+ if (result.skipped === 'no_github_remote') {
167
+ console.log(pc.dim(' Branch protection: skipped (no GitHub origin remote).'));
168
+ return;
169
+ }
170
+ if (result.skipped === 'gh_unavailable') {
171
+ console.log(pc.dim(' Branch protection: skipped (gh CLI unavailable or not authenticated).'));
172
+ return;
173
+ }
174
+ if (result.skipped === 'repo_parse_failed') {
175
+ console.log(pc.dim(' Branch protection: skipped (could not resolve owner/repo from origin).'));
176
+ return;
177
+ }
178
+
179
+ if (result.removed.length > 0) {
180
+ console.log(pc.green(` Branch protection removed: ${result.removed.join(', ')}`));
181
+ }
182
+ if (result.applied.length > 0) {
183
+ console.log(pc.green(` Branch protection applied: ${result.applied.join(', ')}`));
184
+ }
185
+ if (result.pending.length > 0) {
186
+ console.log(
187
+ pc.yellow(
188
+ ` Branch protection pending for non-remote branch(es): ${result.pending.join(', ')} (push branches, then run "climaybe ensure-branches")`
189
+ )
190
+ );
191
+ }
192
+ if (result.failed.length > 0) {
193
+ for (const item of result.failed) {
194
+ console.log(pc.red(` Branch protection ${item.action} failed for ${item.branch}: ${item.message}`));
195
+ }
196
+ }
197
+ if (
198
+ result.applied.length === 0 &&
199
+ result.removed.length === 0 &&
200
+ result.pending.length === 0 &&
201
+ result.failed.length === 0
202
+ ) {
203
+ console.log(pc.dim(` Branch protection: no changes needed (${mode}-store mode).`));
204
+ }
205
+ }
package/src/lib/config.js CHANGED
@@ -261,13 +261,23 @@ export function isCommitlintEnabled(cwd = process.cwd()) {
261
261
  }
262
262
 
263
263
  /**
264
- * Whether bundled Cursor rules, skills, and subagents were installed (init or add-cursor).
264
+ * Whether the bundled AI ruleset (rules, skills, subagents) was installed (init or add-cursor).
265
265
  */
266
266
  export function isCursorSkillsEnabled(cwd = process.cwd()) {
267
267
  const config = readConfig(cwd);
268
268
  return config?.cursor_skills === true;
269
269
  }
270
270
 
271
+ /**
272
+ * Editors bridged to the .config/ai ruleset. Falls back to ['cursor'] for configs
273
+ * written before the multi-editor option existed.
274
+ */
275
+ export function getAiEditors(cwd = process.cwd()) {
276
+ const config = readConfig(cwd);
277
+ const editors = config?.ai_editors;
278
+ return Array.isArray(editors) && editors.length > 0 ? editors : ['cursor'];
279
+ }
280
+
271
281
  /**
272
282
  * Add a store entry to the config.
273
283
  * Returns the updated config.
@@ -1,14 +1,64 @@
1
- import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync } from 'node:fs';
2
- import { join, dirname } from 'node:path';
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ statSync,
6
+ lstatSync,
7
+ rmSync,
8
+ copyFileSync,
9
+ symlinkSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import { join, dirname, relative } from 'node:path';
3
13
  import { fileURLToPath } from 'node:url';
4
14
 
5
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
16
 
7
- /** Bundled Electric Maybe Cursor rules, skills, and subagents (shipped under src/cursor/). */
17
+ /** Bundled Electric Maybe AI rules, skills, and subagents (shipped under src/cursor/). */
8
18
  const BUNDLE_ROOT = join(__dirname, '..', 'cursor');
9
19
 
10
20
  const SKIP_NAMES = new Set(['.DS_Store']);
11
21
 
22
+ /** Single source of truth for AI/editor config inside the target repo. */
23
+ export const AI_CONFIG_DIR = '.config/ai';
24
+
25
+ /** Canonical entry doc that the flat editor files (AGENTS.md, CLAUDE.md, …) point at. */
26
+ export const AI_RULES_ENTRY = `${AI_CONFIG_DIR}/rules.md`;
27
+
28
+ /**
29
+ * Editor "bridges" — each maps an editor's expected path to the shared source of truth.
30
+ * `kind: 'dir'` links a folder (the editor reads rules/skills/agents from it);
31
+ * `kind: 'file'` links a single instructions file to the combined rules entry doc.
32
+ */
33
+ export const EDITOR_BRIDGES = {
34
+ cursor: { label: 'Cursor', bridges: [{ link: '.cursor', target: AI_CONFIG_DIR, kind: 'dir' }] },
35
+ windsurf: { label: 'Windsurf', bridges: [{ link: '.windsurf', target: AI_CONFIG_DIR, kind: 'dir' }] },
36
+ cline: { label: 'Cline / Roo Code', bridges: [{ link: '.clinerules', target: AI_CONFIG_DIR, kind: 'dir' }] },
37
+ claude: { label: 'Claude Code', bridges: [{ link: 'CLAUDE.md', target: AI_RULES_ENTRY, kind: 'file' }] },
38
+ copilot: {
39
+ label: 'GitHub Copilot / VS Code',
40
+ bridges: [{ link: '.github/copilot-instructions.md', target: AI_RULES_ENTRY, kind: 'file' }],
41
+ },
42
+ agents: { label: 'Other editors (AGENTS.md)', bridges: [{ link: 'AGENTS.md', target: AI_RULES_ENTRY, kind: 'file' }] },
43
+ };
44
+
45
+ const RULES_ENTRY_CONTENT = `# Electric Maybe — AI ruleset
46
+
47
+ This file is the shared entry point for AI/editor assistants working in this repo.
48
+ The real content lives in \`${AI_CONFIG_DIR}/\`; every editor reads it through a bridge
49
+ file or symlink, so there is a single source of truth and nothing to duplicate.
50
+
51
+ - **Rules:** \`${AI_CONFIG_DIR}/rules/\` — start with \`00-rule-index.mdc\`
52
+ - **Skills:** \`${AI_CONFIG_DIR}/skills/\`
53
+ - **Agents / subagents:** \`${AI_CONFIG_DIR}/agents/\`
54
+
55
+ Edit files under \`${AI_CONFIG_DIR}/\` only. Bridges (\`.cursor\`, \`.windsurf\`,
56
+ \`.clinerules\`, \`AGENTS.md\`, \`CLAUDE.md\`, \`.github/copilot-instructions.md\`) point back
57
+ here, so a change is picked up by every editor at once.
58
+ `;
59
+
60
+ const isWindows = process.platform === 'win32';
61
+
12
62
  /**
13
63
  * Recursively copy directory tree; skips junk files (e.g. .DS_Store).
14
64
  * @param {string} src
@@ -29,21 +79,109 @@ function copyTree(src, dest) {
29
79
  }
30
80
  }
31
81
 
82
+ function removeIfExists(path) {
83
+ try {
84
+ lstatSync(path);
85
+ rmSync(path, { recursive: true, force: true });
86
+ } catch {
87
+ // nothing to remove
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Create one editor bridge to the shared source of truth.
93
+ * Prefers a symlink (so there is zero duplication); falls back to copying when the
94
+ * platform refuses symlinks (e.g. Windows without privilege).
95
+ * @returns {{ link: string, mode: 'symlink' | 'copy' }}
96
+ */
97
+ function createBridge(cwd, { link, target, kind }) {
98
+ const linkPath = join(cwd, link);
99
+ const absTarget = join(cwd, target);
100
+ mkdirSync(dirname(linkPath), { recursive: true });
101
+ removeIfExists(linkPath);
102
+
103
+ try {
104
+ if (kind === 'dir') {
105
+ // Junctions on Windows need an absolute target and no privilege; POSIX uses a
106
+ // relative symlink so the repo stays portable when moved or cloned.
107
+ const symType = isWindows ? 'junction' : 'dir';
108
+ const symTarget = isWindows ? absTarget : relative(dirname(linkPath), absTarget);
109
+ symlinkSync(symTarget, linkPath, symType);
110
+ } else {
111
+ const symTarget = relative(dirname(linkPath), absTarget);
112
+ symlinkSync(symTarget, linkPath, 'file');
113
+ }
114
+ return { link, mode: 'symlink' };
115
+ } catch {
116
+ // Fallback: copy so the file/folder still exists even without symlink support.
117
+ if (kind === 'dir') {
118
+ copyTree(absTarget, linkPath);
119
+ } else {
120
+ copyFileSync(absTarget, linkPath);
121
+ }
122
+ return { link, mode: 'copy' };
123
+ }
124
+ }
125
+
32
126
  /**
33
- * Install bundled `.cursor/rules`, `.cursor/skills`, and `.cursor/agents` into the target repo.
127
+ * Install the Electric Maybe AI bundle into `.config/ai/` (single source of truth) and
128
+ * create bridge files/symlinks for the chosen editors.
129
+ *
34
130
  * @param {string} [cwd] - Working directory (default process.cwd())
35
- * @returns {boolean} - false if bundle source is missing (broken install)
131
+ * @param {{ editors?: string[] }} [opts] - Editor keys from EDITOR_BRIDGES (default: ['cursor'])
132
+ * @returns {{ ok: boolean, editors: string[], bridges: Array<{link: string, mode: string}> }}
36
133
  */
37
- export function scaffoldCursorBundle(cwd = process.cwd()) {
134
+ export function scaffoldAiConfig(cwd = process.cwd(), { editors = ['cursor'] } = {}) {
38
135
  const rulesSrc = join(BUNDLE_ROOT, 'rules');
39
136
  const skillsSrc = join(BUNDLE_ROOT, 'skills');
40
137
  const agentsSrc = join(BUNDLE_ROOT, 'agents');
41
138
  if (!existsSync(rulesSrc) || !existsSync(skillsSrc) || !existsSync(agentsSrc)) {
42
- return false;
139
+ return { ok: false, editors: [], bridges: [] };
140
+ }
141
+
142
+ const aiRoot = join(cwd, AI_CONFIG_DIR);
143
+ copyTree(rulesSrc, join(aiRoot, 'rules'));
144
+ copyTree(skillsSrc, join(aiRoot, 'skills'));
145
+ copyTree(agentsSrc, join(aiRoot, 'agents'));
146
+ writeFileSync(join(cwd, AI_RULES_ENTRY), RULES_ENTRY_CONTENT, 'utf-8');
147
+
148
+ const selected = editors.filter((key) => EDITOR_BRIDGES[key]);
149
+ const bridges = [];
150
+ for (const key of selected) {
151
+ for (const bridge of EDITOR_BRIDGES[key].bridges) {
152
+ bridges.push(createBridge(cwd, bridge));
153
+ }
154
+ }
155
+ return { ok: true, editors: selected, bridges };
156
+ }
157
+
158
+ /**
159
+ * Pretty-print the outcome of scaffoldAiConfig() with picocolors. Lazily imported so
160
+ * this module stays usable in non-CLI contexts (tests) without color deps.
161
+ * @param {ReturnType<typeof scaffoldAiConfig>} result
162
+ * @param {{ pc: import('picocolors').Picocolors }} deps
163
+ */
164
+ export function logAiConfigResult(result, { pc }) {
165
+ if (!result.ok) {
166
+ console.log(pc.yellow(' AI ruleset not found in this climaybe install (skipped).'));
167
+ return;
168
+ }
169
+ console.log(pc.green(` Electric Maybe AI ruleset → ${AI_CONFIG_DIR}/ (rules, skills, agents)`));
170
+ if (result.bridges.length > 0) {
171
+ const links = result.bridges.map((b) => (b.mode === 'copy' ? `${b.link} (copy)` : b.link));
172
+ console.log(pc.dim(` Editor bridges: ${links.join(', ')}`));
173
+ if (result.bridges.some((b) => b.mode === 'copy')) {
174
+ console.log(pc.dim(' (Some bridges were copied because this platform blocked symlinks.)'));
175
+ }
43
176
  }
44
- const cursorRoot = join(cwd, '.cursor');
45
- copyTree(rulesSrc, join(cursorRoot, 'rules'));
46
- copyTree(skillsSrc, join(cursorRoot, 'skills'));
47
- copyTree(agentsSrc, join(cursorRoot, 'agents'));
48
- return true;
177
+ console.log(pc.dim(` Edit rules in ${AI_CONFIG_DIR}/ — every bridged editor reads the same files.`));
178
+ }
179
+
180
+ /**
181
+ * Back-compat wrapper for callers that only want the Cursor bridge.
182
+ * @param {string} [cwd]
183
+ * @returns {boolean} false if bundle source is missing (broken install)
184
+ */
185
+ export function scaffoldCursorBundle(cwd = process.cwd()) {
186
+ return scaffoldAiConfig(cwd, { editors: ['cursor'] }).ok;
49
187
  }
package/src/lib/git.js CHANGED
@@ -119,6 +119,18 @@ export function hasOriginRemote(cwd = process.cwd()) {
119
119
  }
120
120
  }
121
121
 
122
+ /**
123
+ * Add an `origin` remote from an "owner/repo" slug for the given host ('github' | 'gitlab').
124
+ * No-op (returns the existing URL) if origin already exists. Returns the remote URL on success.
125
+ */
126
+ export function addOriginRemote(slug, host = 'github', cwd = process.cwd()) {
127
+ if (hasOriginRemote(cwd)) return exec('git remote get-url origin', cwd);
128
+ const base = host === 'gitlab' ? 'https://gitlab.com' : 'https://github.com';
129
+ const url = `${base}/${slug}.git`;
130
+ exec(`git remote add origin ${url}`, cwd);
131
+ return url;
132
+ }
133
+
122
134
  /**
123
135
  * Push branches to origin if remote exists.
124
136
  */
@@ -142,13 +142,28 @@ export async function promptBuildWorkflows() {
142
142
  const { enableBuildWorkflows } = await prompts({
143
143
  type: 'confirm',
144
144
  name: 'enableBuildWorkflows',
145
- message: 'Enable build + Lighthouse workflows?',
145
+ message: 'Enable build workflows? (bundle _scripts JS + compile Tailwind in CI)',
146
146
  initial: true,
147
147
  });
148
148
 
149
149
  return !!enableBuildWorkflows;
150
150
  }
151
151
 
152
+ /**
153
+ * Ask whether Lighthouse CI should run as part of the build pipeline.
154
+ * Only meaningful when build workflows are enabled (Lighthouse runs after the build).
155
+ */
156
+ export async function promptLighthouseWorkflows() {
157
+ const { enableLighthouseWorkflows } = await prompts({
158
+ type: 'confirm',
159
+ name: 'enableLighthouseWorkflows',
160
+ message: 'Also run Lighthouse CI on the staging branch? (performance + a11y budget)',
161
+ initial: true,
162
+ });
163
+
164
+ return !!enableLighthouseWorkflows;
165
+ }
166
+
152
167
  /**
153
168
  * Ask whether to scaffold local theme dev-kit files (configs, ignores, editor tasks).
154
169
  */
@@ -193,20 +208,61 @@ export async function promptCommitlint() {
193
208
  }
194
209
 
195
210
  /**
196
- * Ask whether to install bundled Cursor rules, skills, and subagents (.cursor/rules, .cursor/skills, .cursor/agents).
211
+ * Ask whether to reconcile GitHub branch protection for the configured mode
212
+ * (protect main in single-store, or each live-<alias> in multi-store).
213
+ */
214
+ export async function promptBranchProtection() {
215
+ const { enableBranchProtection } = await prompts({
216
+ type: 'confirm',
217
+ name: 'enableBranchProtection',
218
+ message:
219
+ 'Set up GitHub branch protection? (require PRs on production branches; needs a GitHub origin + authenticated gh CLI)',
220
+ initial: true,
221
+ });
222
+
223
+ return !!enableBranchProtection;
224
+ }
225
+
226
+ /**
227
+ * Ask whether to install the bundled Electric Maybe AI ruleset (rules, skills, subagents)
228
+ * into a single `.config/ai/` source of truth.
197
229
  */
198
230
  export async function promptCursorSkills() {
199
231
  const { enableCursorSkills } = await prompts({
200
232
  type: 'confirm',
201
233
  name: 'enableCursorSkills',
202
- message:
203
- 'Install Electric Maybe Cursor bundle? (rules, skills, subagents e.g. theme-translator in .cursor/)',
234
+ message: 'Install the Electric Maybe AI ruleset? (rules, skills, subagents in .config/ai/)',
204
235
  initial: true,
205
236
  });
206
237
 
207
238
  return !!enableCursorSkills;
208
239
  }
209
240
 
241
+ /**
242
+ * Ask which editors to bridge to the shared `.config/ai/` ruleset. Returns an array of
243
+ * editor keys understood by EDITOR_BRIDGES in cursor-bundle.js. Defaults to Cursor.
244
+ */
245
+ export async function promptAiEditors() {
246
+ const { editors } = await prompts({
247
+ type: 'multiselect',
248
+ name: 'editors',
249
+ message: 'Which editors should read the ruleset? (bridged to .config/ai/, no duplication)',
250
+ instructions: false,
251
+ hint: '- space to toggle, enter to confirm',
252
+ choices: [
253
+ { title: 'Cursor', value: 'cursor', selected: true },
254
+ { title: 'Claude Code (CLAUDE.md)', value: 'claude', selected: true },
255
+ { title: 'GitHub Copilot / VS Code', value: 'copilot', selected: false },
256
+ { title: 'Windsurf', value: 'windsurf', selected: false },
257
+ { title: 'Cline / Roo Code', value: 'cline', selected: false },
258
+ { title: 'Other editors (AGENTS.md)', value: 'agents', selected: false },
259
+ ],
260
+ });
261
+
262
+ // `prompts` returns undefined on cancel / non-TTY; fall back to Cursor.
263
+ return Array.isArray(editors) && editors.length > 0 ? editors : ['cursor'];
264
+ }
265
+
210
266
  /**
211
267
  * Prompt for package.json name when creating a new package.json.
212
268
  */
@@ -287,6 +343,44 @@ export async function promptConfigureCISecrets() {
287
343
  return host ?? 'skip';
288
344
  }
289
345
 
346
+ /**
347
+ * When no GitHub/GitLab origin remote exists, ask how to proceed at the CI secrets step.
348
+ * Returns 'add' (prompt for owner/repo and add origin), or 'skip' (do it later).
349
+ */
350
+ export async function promptNoRemoteAction(hostName) {
351
+ const { action } = await prompts({
352
+ type: 'select',
353
+ name: 'action',
354
+ message: `CI secrets need a ${hostName} repo, but this folder has no "origin" remote. What now?`,
355
+ choices: [
356
+ { title: `Add an ${hostName} remote now and continue`, value: 'add' },
357
+ { title: 'Skip for now (add secrets later in repo settings)', value: 'skip' },
358
+ ],
359
+ initial: 0,
360
+ });
361
+ return action ?? 'skip';
362
+ }
363
+
364
+ /**
365
+ * Prompt for an owner/repo slug to wire up as the git origin remote.
366
+ * Returns a trimmed "owner/repo" string, or null if skipped/invalid.
367
+ */
368
+ export async function promptOwnerRepo(hostName) {
369
+ const { slug } = await prompts({
370
+ type: 'text',
371
+ name: 'slug',
372
+ message: `${hostName} repository (owner/repo)`,
373
+ validate: (v) => {
374
+ const s = String(v || '').trim();
375
+ if (!s) return 'Enter owner/repo, or press Esc to skip';
376
+ if (!/^[^/\s]+\/[^/\s]+$/.test(s)) return 'Use the form owner/repo (e.g. electricmaybe/climaybe)';
377
+ return true;
378
+ },
379
+ });
380
+ const s = String(slug || '').trim();
381
+ return /^[^/\s]+\/[^/\s]+$/.test(s) ? s : null;
382
+ }
383
+
290
384
  /**
291
385
  * Ask whether to update existing CI secrets (when some are already set). Returns true to update, false to skip.
292
386
  */
@@ -17,6 +17,10 @@ _scripts
17
17
  .cursor
18
18
  .cursorrules
19
19
  .config
20
+ .windsurf
21
+ .clinerules
22
+ AGENTS.md
23
+ CLAUDE.md
20
24
  .backups
21
25
  .github
22
26
  .vscode
@@ -10,6 +10,11 @@ on:
10
10
  - 'docs/**'
11
11
  - '.github/**'
12
12
  - '.cursor/**'
13
+ - '.config/**'
14
+ - '.windsurf/**'
15
+ - '.clinerules/**'
16
+ - 'AGENTS.md'
17
+ - 'CLAUDE.md'
13
18
  - '.claude/**'
14
19
  - '.eser/**'
15
20
  - 'README*'
@@ -43,11 +48,20 @@ jobs:
43
48
  SHOP_ACCESS_TOKEN: ${{ secrets.SHOP_ACCESS_TOKEN }}
44
49
  LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
45
50
  steps:
51
+ - name: Checkout code
52
+ uses: actions/checkout@v4
53
+
46
54
  - name: Check if Lighthouse CI should run
47
55
  id: check
48
56
  run: |
49
57
  SKIP_REASONS=()
50
58
  BR="${{ github.ref_name }}"
59
+ # Lighthouse is opt-in via climaybe.config.json (lighthouse_workflows).
60
+ # Default to enabled when the flag is absent (older configs).
61
+ LH_ENABLED=$(node -e "try{process.stdout.write(require('./climaybe.config.json').lighthouse_workflows===false?'false':'true')}catch(e){process.stdout.write('true')}" 2>/dev/null || echo true)
62
+ if [[ "$LH_ENABLED" != "true" ]]; then
63
+ SKIP_REASONS+=("Lighthouse disabled in climaybe.config.json (lighthouse_workflows: false)")
64
+ fi
51
65
  if [[ "$BR" == "staging" ]]; then
52
66
  :
53
67
  else
@@ -124,7 +124,7 @@ jobs:
124
124
  if [ -n "$STAGING_THEME_ID" ]; then
125
125
  PREVIEW_URL="https://${DOMAIN}?preview_theme_id=${STAGING_THEME_ID}"
126
126
  CUSTOMIZE_URL="https://${DOMAIN}/admin/themes/${STAGING_THEME_ID}/editor"
127
- BODY="${BODY}"$'\n\n'"**Customize URL (Staging Theme):** ${CUSTOMIZE_URL}"$'\n'"**Preview URL (Staging Theme):** ${PREVIEW_URL}"
127
+ BODY="${BODY}"$'\n\n'"[${ALIAS} preview](${PREVIEW_URL}) · [customize](${CUSTOMIZE_URL})"
128
128
  else
129
129
  BODY="${BODY}"$'\n\n'"⚠️ Staging theme link not found for branch \`${STAGING}\`."$'\n'"Expected naming: \`${REPO_NAME}/${STAGING}\` (or suffix match \`/${STAGING}\`)."
130
130
  fi
@@ -107,7 +107,7 @@ jobs:
107
107
  if (host && tid) {
108
108
  const previewUrl = `https://${host}?preview_theme_id=${tid}`;
109
109
  const customizeUrl = `https://${host}/admin/themes/${tid}/editor`;
110
- parts.push('', `**${alias}**`, `- Customize: ${customizeUrl}`, `- Preview: ${previewUrl}`);
110
+ parts.push('', `- [${alias} preview](${previewUrl}) · [customize](${customizeUrl})`);
111
111
  }
112
112
  }
113
113
  } else if (useFragments) {
@@ -128,8 +128,11 @@ jobs:
128
128
  previewUrl = `https://${storeDomain}?preview_theme_id=${themeId}`;
129
129
  customizeUrl = `https://${storeDomain}/admin/themes/${themeId}/editor`;
130
130
  }
131
- if (customizeUrl) parts.push('', `**Customize URL:** ${customizeUrl}`);
132
- if (previewUrl) parts.push('', `**Preview URL:** ${previewUrl}`);
131
+ if (previewUrl) {
132
+ const links = [`[theme preview](${previewUrl})`];
133
+ if (customizeUrl) links.push(`[customize](${customizeUrl})`);
134
+ parts.push('', links.join(' · '));
135
+ }
133
136
  if (shareOutput.trim()) {
134
137
  parts.push('', '### Share Output', '```', shareOutput, '```');
135
138
  }