bitcompass 0.3.7 → 0.3.9

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.
@@ -6,6 +6,10 @@ import ora from 'ora';
6
6
  import { getTokenFilePath, loadConfig, saveCredentials } from '../auth/config.js';
7
7
  import { DEFAULT_SUPABASE_ANON_KEY, DEFAULT_SUPABASE_URL } from '../auth/defaults.js';
8
8
  const CALLBACK_PORT = 38473;
9
+ /** Hardcoded Compass rules URL: prod or local testing. Used for "See rules on Compass" link after login. */
10
+ const COMPASS_RULES_URL_PROD = 'https://bitcompass.vercel.app/rules';
11
+ const COMPASS_RULES_URL_DEV = 'http://localhost:8080/rules';
12
+ const getCompassRulesUrl = () => process.env.NODE_ENV === 'development' ? COMPASS_RULES_URL_DEV : COMPASS_RULES_URL_PROD;
9
13
  /** Design tokens matching src/index.css (light theme) */
10
14
  const STYLES = {
11
15
  background: 'hsl(0, 0%, 98%)',
@@ -18,7 +22,25 @@ const STYLES = {
18
22
  radius: '0.625rem',
19
23
  shadow: '0 10px 15px -3px hsl(220 13% 11% / 0.08), 0 4px 6px -4px hsl(220 13% 11% / 0.05)',
20
24
  };
21
- const CALLBACK_SUCCESS_HTML = `<!DOCTYPE html>
25
+ /**
26
+ * Builds the Compass rules URL, optionally with auth hash so the web app can set the session
27
+ * without asking the user to log in again (use-auth.ts reads #access_token and #refresh_token).
28
+ */
29
+ const compassRulesUrlWithSession = (baseUrl, session) => {
30
+ if (!session?.access_token || !session?.refresh_token)
31
+ return baseUrl;
32
+ const hash = `access_token=${encodeURIComponent(session.access_token)}&refresh_token=${encodeURIComponent(session.refresh_token)}`;
33
+ return `${baseUrl}#${hash}`;
34
+ };
35
+ /** Builds the post-login success (recap) page HTML with Compass rules CTA link. Session in URL hash persists login on the website. */
36
+ const buildCallbackSuccessHtml = (compassRulesUrl, session) => {
37
+ const href = compassRulesUrlWithSession(compassRulesUrl, session);
38
+ const compassCtaBlock = `
39
+ <div class="compass-cta-block">
40
+ <a href="${escapeHtml(href)}" class="compass-cta" target="_blank" rel="noopener noreferrer">See Available Rules on Compass</a>
41
+ <p class="compass-cta-hint">Opens in the same browser; you’ll be signed in automatically.</p>
42
+ </div>`;
43
+ return `<!DOCTYPE html>
22
44
  <html lang="en">
23
45
  <head>
24
46
  <meta charset="UTF-8" />
@@ -119,6 +141,28 @@ const CALLBACK_SUCCESS_HTML = `<!DOCTYPE html>
119
141
  }
120
142
  .copy-btn:hover { opacity: 0.9; }
121
143
  .copy-btn.copied { background: ${STYLES.muted}; cursor: default; }
144
+ .compass-cta-block {
145
+ margin-top: 1.25rem;
146
+ padding-top: 1.25rem;
147
+ border-top: 1px solid ${STYLES.border};
148
+ }
149
+ .compass-cta {
150
+ display: inline-block;
151
+ padding: 0.5rem 1rem;
152
+ background: ${STYLES.primary};
153
+ color: ${STYLES.primaryForeground};
154
+ font-size: 0.875rem;
155
+ font-weight: 600;
156
+ text-decoration: none;
157
+ border-radius: 0.5rem;
158
+ transition: opacity 0.15s;
159
+ }
160
+ .compass-cta:hover { opacity: 0.9; }
161
+ .compass-cta-hint {
162
+ margin: 0.5rem 0 0;
163
+ font-size: 0.75rem;
164
+ color: ${STYLES.muted};
165
+ }
122
166
  </style>
123
167
  </head>
124
168
  <body>
@@ -131,7 +175,7 @@ const CALLBACK_SUCCESS_HTML = `<!DOCTYPE html>
131
175
  </div>
132
176
  <h1>You're all set</h1>
133
177
  <p class="muted">You're logged in successfully. You can close this window safely—your credentials are saved and the CLI is ready to use.</p>
134
- <p class="hint">Return to your terminal to continue.</p>
178
+ <p class="hint">Return to your terminal to continue.</p>${compassCtaBlock}
135
179
  <div class="verify-block">
136
180
  <p class="muted" style="margin:0">Verify in terminal:</p>
137
181
  <div class="cmd-row">
@@ -159,6 +203,7 @@ const CALLBACK_SUCCESS_HTML = `<!DOCTYPE html>
159
203
  </script>
160
204
  </body>
161
205
  </html>`;
206
+ };
162
207
  const escapeHtml = (s) => s
163
208
  .replace(/&/g, '&amp;')
164
209
  .replace(/</g, '&lt;')
@@ -301,7 +346,7 @@ export const runLogin = async () => {
301
346
  const tokenPath = getTokenFilePath();
302
347
  saveCredentials(creds);
303
348
  res.writeHead(200, { 'Content-Type': 'text/html' });
304
- res.end(CALLBACK_SUCCESS_HTML);
349
+ res.end(buildCallbackSuccessHtml(getCompassRulesUrl(), session));
305
350
  spinner.succeed(chalk.green('Logged in successfully.'));
306
351
  console.log(chalk.dim('Credentials saved to:'), tokenPath);
307
352
  server.close();
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import { loadCredentials } from '../auth/config.js';
5
5
  import { searchRules, fetchRules, getRuleById, insertRule } from '../api/client.js';
6
6
  import { pullRuleToFile } from '../lib/rule-file-ops.js';
7
+ import { parseRuleMdcContent } from '../lib/mdc-format.js';
7
8
  import { formatList, shouldUseTable } from '../lib/list-format.js';
8
9
  export const runRulesSearch = async (query, options) => {
9
10
  if (!loadCredentials()) {
@@ -108,9 +109,23 @@ export const runRulesPush = async (file) => {
108
109
  payload = JSON.parse(raw);
109
110
  }
110
111
  catch {
111
- const lines = raw.split('\n');
112
- const title = lines[0].replace(/^#\s*/, '') || 'Untitled';
113
- payload = { kind: 'rule', title, description: '', body: raw };
112
+ const parsed = parseRuleMdcContent(raw);
113
+ if (parsed) {
114
+ const titleFromBody = parsed.body.split('\n')[0]?.replace(/^#\s*/, '').trim() || 'Untitled';
115
+ payload = {
116
+ kind: 'rule',
117
+ title: titleFromBody,
118
+ description: parsed.description,
119
+ body: parsed.body,
120
+ globs: parsed.globs ?? undefined,
121
+ always_apply: parsed.alwaysApply,
122
+ };
123
+ }
124
+ else {
125
+ const lines = raw.split('\n');
126
+ const title = lines[0].replace(/^#\s*/, '') || 'Untitled';
127
+ payload = { kind: 'rule', title, description: '', body: raw };
128
+ }
114
129
  }
115
130
  }
116
131
  else {
@@ -0,0 +1,16 @@
1
+ import type { Rule } from '../types.js';
2
+ /**
3
+ * Builds Cursor .mdc content for a rule: YAML frontmatter (description, globs, alwaysApply) then body.
4
+ */
5
+ export declare const buildRuleMdcContent: (rule: Rule) => string;
6
+ export interface ParsedMdcFrontmatter {
7
+ description: string;
8
+ globs?: string;
9
+ alwaysApply: boolean;
10
+ body: string;
11
+ }
12
+ /**
13
+ * Parses .mdc content: frontmatter (description, globs, alwaysApply) and body.
14
+ * Returns null if the file does not start with --- (not frontmatter format).
15
+ */
16
+ export declare const parseRuleMdcContent: (raw: string) => ParsedMdcFrontmatter | null;
@@ -0,0 +1,66 @@
1
+ const FRONTMATTER_DELIM = '---';
2
+ /**
3
+ * Builds Cursor .mdc content for a rule: YAML frontmatter (description, globs, alwaysApply) then body.
4
+ */
5
+ export const buildRuleMdcContent = (rule) => {
6
+ const lines = [FRONTMATTER_DELIM];
7
+ lines.push(`description: ${escapeYamlValue(rule.description ?? '')}`);
8
+ if (rule.globs != null && String(rule.globs).trim() !== '') {
9
+ lines.push(`globs: ${escapeYamlValue(String(rule.globs).trim())}`);
10
+ }
11
+ lines.push(`alwaysApply: ${rule.always_apply === true}`);
12
+ lines.push(FRONTMATTER_DELIM);
13
+ lines.push('');
14
+ lines.push(rule.body.trimEnd());
15
+ if (!rule.body.endsWith('\n')) {
16
+ lines.push('');
17
+ }
18
+ return lines.join('\n');
19
+ };
20
+ const escapeYamlValue = (s) => {
21
+ if (/^[a-z0-9-]+$/i.test(s) && !s.includes(':'))
22
+ return s;
23
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
24
+ };
25
+ /**
26
+ * Parses .mdc content: frontmatter (description, globs, alwaysApply) and body.
27
+ * Returns null if the file does not start with --- (not frontmatter format).
28
+ */
29
+ export const parseRuleMdcContent = (raw) => {
30
+ const trimmed = raw.trimStart();
31
+ if (!trimmed.startsWith(FRONTMATTER_DELIM)) {
32
+ return null;
33
+ }
34
+ const rest = trimmed.slice(FRONTMATTER_DELIM.length);
35
+ const endIdx = rest.indexOf('\n' + FRONTMATTER_DELIM);
36
+ if (endIdx === -1) {
37
+ return null;
38
+ }
39
+ const frontmatterBlock = rest.slice(0, endIdx).trim();
40
+ const body = rest.slice(endIdx + FRONTMATTER_DELIM.length + 1).trimStart();
41
+ let description = '';
42
+ let globs;
43
+ let alwaysApply = false;
44
+ for (const line of frontmatterBlock.split('\n')) {
45
+ const colonIdx = line.indexOf(':');
46
+ if (colonIdx === -1)
47
+ continue;
48
+ const key = line.slice(0, colonIdx).trim();
49
+ let value = line.slice(colonIdx + 1).trim();
50
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
51
+ value = value.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
52
+ }
53
+ switch (key) {
54
+ case 'description':
55
+ description = value;
56
+ break;
57
+ case 'globs':
58
+ globs = value;
59
+ break;
60
+ case 'alwaysApply':
61
+ alwaysApply = value === 'true' || value === '1';
62
+ break;
63
+ }
64
+ }
65
+ return { description, globs, alwaysApply, body };
66
+ };
@@ -4,11 +4,11 @@ import type { Rule } from '../types.js';
4
4
  */
5
5
  export declare const getCacheDir: () => string;
6
6
  /**
7
- * Gets the cached file path for a rule by ID
7
+ * Gets the cached file path for a rule by ID (rules use .mdc, others use .md)
8
8
  */
9
9
  export declare const getCachedRulePath: (rule: Rule) => string;
10
10
  /**
11
11
  * Ensures a rule is cached. Downloads and caches it if not present or outdated.
12
- * Returns the path to the cached file.
12
+ * Returns the path to the cached file. Rules are stored as .mdc with Cursor frontmatter.
13
13
  */
14
14
  export declare const ensureRuleCached: (id: string) => Promise<string>;
@@ -2,7 +2,8 @@ import { existsSync, mkdirSync, writeFileSync, statSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { getConfigDir } from '../auth/config.js';
4
4
  import { getRuleById } from '../api/client.js';
5
- import { ruleFilename, solutionFilename } from './slug.js';
5
+ import { ruleFilename, solutionFilename, skillFilename, commandFilename } from './slug.js';
6
+ import { buildRuleMdcContent } from './mdc-format.js';
6
7
  /**
7
8
  * Gets the cache directory for rules (~/.bitcompass/cache/rules/)
8
9
  */
@@ -14,18 +15,22 @@ export const getCacheDir = () => {
14
15
  return cacheDir;
15
16
  };
16
17
  /**
17
- * Gets the cached file path for a rule by ID
18
+ * Gets the cached file path for a rule by ID (rules use .mdc, others use .md)
18
19
  */
19
20
  export const getCachedRulePath = (rule) => {
20
21
  const cacheDir = getCacheDir();
21
22
  const filename = rule.kind === 'solution'
22
23
  ? solutionFilename(rule.title, rule.id)
23
- : ruleFilename(rule.title, rule.id);
24
+ : rule.kind === 'skill'
25
+ ? skillFilename(rule.title, rule.id)
26
+ : rule.kind === 'command'
27
+ ? commandFilename(rule.title, rule.id)
28
+ : ruleFilename(rule.title, rule.id);
24
29
  return join(cacheDir, `${rule.id}-${filename}`);
25
30
  };
26
31
  /**
27
32
  * Ensures a rule is cached. Downloads and caches it if not present or outdated.
28
- * Returns the path to the cached file.
33
+ * Returns the path to the cached file. Rules are stored as .mdc with Cursor frontmatter.
29
34
  */
30
35
  export const ensureRuleCached = async (id) => {
31
36
  const rule = await getRuleById(id);
@@ -35,9 +40,11 @@ export const ensureRuleCached = async (id) => {
35
40
  const cachedPath = getCachedRulePath(rule);
36
41
  const needsUpdate = !existsSync(cachedPath) || isCacheOutdated(cachedPath, rule);
37
42
  if (needsUpdate) {
38
- const content = rule.kind === 'solution'
39
- ? `# ${rule.title}\n\n${rule.description}\n\n## Solution\n\n${rule.body}\n`
40
- : `# ${rule.title}\n\n${rule.description}\n\n${rule.body}\n`;
43
+ const content = rule.kind === 'rule'
44
+ ? buildRuleMdcContent(rule)
45
+ : rule.kind === 'solution'
46
+ ? `# ${rule.title}\n\n${rule.description}\n\n## Solution\n\n${rule.body}\n`
47
+ : `# ${rule.title}\n\n${rule.description}\n\n${rule.body}\n`;
41
48
  writeFileSync(cachedPath, content, 'utf-8');
42
49
  }
43
50
  return cachedPath;
@@ -5,6 +5,7 @@ import { getRuleById } from '../api/client.js';
5
5
  import { getProjectConfig } from '../auth/project-config.js';
6
6
  import { ruleFilename, solutionFilename, skillFilename, commandFilename } from './slug.js';
7
7
  import { ensureRuleCached } from './rule-cache.js';
8
+ import { buildRuleMdcContent } from './mdc-format.js';
8
9
  /**
9
10
  * Pulls a rule or solution to a file using symbolic links (like Bun init).
10
11
  * Returns the file path where it was written/linked.
@@ -79,23 +80,12 @@ export const pullRuleToFile = async (id, options = {}) => {
79
80
  }
80
81
  }
81
82
  else {
82
- // Fallback: copy file content (for compatibility or when symlinks aren't desired)
83
- let content;
84
- switch (rule.kind) {
85
- case 'solution':
86
- content = `# ${rule.title}\n\n${rule.description}\n\n## Solution\n\n${rule.body}\n`;
87
- break;
88
- case 'skill':
89
- content = `# ${rule.title}\n\n${rule.description}\n\n${rule.body}\n`;
90
- break;
91
- case 'command':
92
- content = `# ${rule.title}\n\n${rule.description}\n\n${rule.body}\n`;
93
- break;
94
- case 'rule':
95
- default:
96
- content = `# ${rule.title}\n\n${rule.description}\n\n${rule.body}\n`;
97
- break;
98
- }
83
+ // Fallback: copy file content (rules as .mdc with frontmatter, others as .md)
84
+ const content = rule.kind === 'rule'
85
+ ? buildRuleMdcContent(rule)
86
+ : rule.kind === 'solution'
87
+ ? `# ${rule.title}\n\n${rule.description}\n\n## Solution\n\n${rule.body}\n`
88
+ : `# ${rule.title}\n\n${rule.description}\n\n${rule.body}\n`;
99
89
  writeFileSync(filename, content);
100
90
  }
101
91
  return filename;
@@ -4,7 +4,7 @@
4
4
  */
5
5
  export declare const titleToSlug: (title: string) => string;
6
6
  /**
7
- * Returns the rule filename (e.g. rule-strava-api-authentication-flow.md).
7
+ * Returns the rule filename in Cursor .mdc format (e.g. rule-strava-api-authentication-flow.mdc).
8
8
  * Falls back to id if slug is empty.
9
9
  */
10
10
  export declare const ruleFilename: (title: string, id: string) => string;
package/dist/lib/slug.js CHANGED
@@ -14,13 +14,13 @@ export const titleToSlug = (title) => {
14
14
  .replace(/^-|-$/g, '');
15
15
  };
16
16
  /**
17
- * Returns the rule filename (e.g. rule-strava-api-authentication-flow.md).
17
+ * Returns the rule filename in Cursor .mdc format (e.g. rule-strava-api-authentication-flow.mdc).
18
18
  * Falls back to id if slug is empty.
19
19
  */
20
20
  export const ruleFilename = (title, id) => {
21
21
  const slug = titleToSlug(title);
22
22
  const base = slug ? `rule-${slug}` : `rule-${id}`;
23
- return `${base}.md`;
23
+ return `${base}.mdc`;
24
24
  };
25
25
  /**
26
26
  * Returns the solution filename (e.g. solution-strava-api-authentication-flow.md).
@@ -139,7 +139,7 @@ function createStdioServer() {
139
139
  },
140
140
  {
141
141
  name: 'update-rule',
142
- description: 'Use when the user wants to edit an existing rule or solution they own. Pass id and any fields to update (title, description, body, context, examples, technologies). Returns updated metadata. Requires authentication.',
142
+ description: 'Use when the user wants to edit an existing rule or solution they own. Pass id and any fields to update (title, description, body, context, examples, technologies, globs, always_apply). Returns updated metadata. Requires authentication.',
143
143
  inputSchema: {
144
144
  type: 'object',
145
145
  properties: {
@@ -150,6 +150,8 @@ function createStdioServer() {
150
150
  context: { type: 'string', description: 'Updated context' },
151
151
  examples: { type: 'array', items: { type: 'string' }, description: 'Updated examples array' },
152
152
  technologies: { type: 'array', items: { type: 'string' }, description: 'Updated technologies array' },
153
+ globs: { type: 'string', description: 'Glob patterns for when rule applies (e.g. "*.ts, *.tsx")' },
154
+ always_apply: { type: 'boolean', description: 'If true, Cursor applies this rule globally' },
153
155
  },
154
156
  required: ['id'],
155
157
  },
@@ -399,6 +401,8 @@ function createStdioServer() {
399
401
  context: rule.context ?? null,
400
402
  examples: rule.examples ?? [],
401
403
  technologies: rule.technologies ?? [],
404
+ globs: rule.globs ?? null,
405
+ always_apply: rule.always_apply ?? false,
402
406
  author: rule.author_display_name ?? null,
403
407
  created_at: rule.created_at,
404
408
  updated_at: rule.updated_at,
@@ -459,6 +463,10 @@ function createStdioServer() {
459
463
  updates.examples = args.examples;
460
464
  if (Array.isArray(args.technologies))
461
465
  updates.technologies = args.technologies;
466
+ if (args.globs !== undefined)
467
+ updates.globs = args.globs || null;
468
+ if (args.always_apply !== undefined)
469
+ updates.always_apply = Boolean(args.always_apply);
462
470
  try {
463
471
  const updated = await updateRule(id, updates);
464
472
  return {
package/dist/types.d.ts CHANGED
@@ -11,6 +11,10 @@ export interface Rule {
11
11
  user_id: string;
12
12
  author_display_name?: string | null;
13
13
  version?: string | null;
14
+ /** Optional glob patterns for when the rule applies (e.g. "*.ts, *.tsx"). Used in .mdc frontmatter. */
15
+ globs?: string | null;
16
+ /** If true, Cursor applies this rule globally. Default false. Used in .mdc frontmatter. */
17
+ always_apply?: boolean;
14
18
  created_at: string;
15
19
  updated_at: string;
16
20
  }
@@ -23,6 +27,8 @@ export interface RuleInsert {
23
27
  examples?: string[];
24
28
  technologies?: string[];
25
29
  version?: string;
30
+ globs?: string | null;
31
+ always_apply?: boolean;
26
32
  }
27
33
  export interface StoredCredentials {
28
34
  access_token: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bitcompass",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "BitCompass CLI - rules, solutions, and MCP server",
5
5
  "type": "module",
6
6
  "bin": {