bitcompass 0.3.2 → 0.3.4

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.
@@ -2,5 +2,6 @@ export declare const runRulesSearch: (query?: string) => Promise<void>;
2
2
  export declare const runRulesList: () => Promise<void>;
3
3
  export declare const runRulesPull: (id?: string, options?: {
4
4
  global?: boolean;
5
+ copy?: boolean;
5
6
  }) => Promise<void>;
6
7
  export declare const runRulesPush: (file?: string) => Promise<void>;
@@ -1,13 +1,9 @@
1
1
  import inquirer from 'inquirer';
2
2
  import ora from 'ora';
3
3
  import chalk from 'chalk';
4
- import { mkdirSync, writeFileSync } from 'fs';
5
- import { join } from 'path';
6
- import { homedir } from 'os';
7
4
  import { loadCredentials } from '../auth/config.js';
8
- import { getProjectConfig } from '../auth/project-config.js';
9
5
  import { searchRules, fetchRules, getRuleById, insertRule } from '../api/client.js';
10
- import { ruleFilename } from '../lib/slug.js';
6
+ import { pullRuleToFile } from '../lib/rule-file-ops.js';
11
7
  export const runRulesSearch = async (query) => {
12
8
  if (!loadCredentials()) {
13
9
  console.error(chalk.red('Not logged in. Run bitcompass login.'));
@@ -66,28 +62,28 @@ export const runRulesPull = async (id, options) => {
66
62
  ]);
67
63
  targetId = choice.id;
68
64
  }
69
- const rule = await getRuleById(targetId);
70
- if (!rule) {
71
- console.error(chalk.red('Rule not found.'));
72
- process.exit(1);
73
- }
74
- let outDir;
75
- if (options?.global) {
76
- // Use global location: ~/.cursor/rules/
77
- outDir = join(homedir(), '.cursor', 'rules');
78
- }
79
- else {
80
- // Use project config (default behavior)
81
- const { outputPath } = getProjectConfig({ warnIfMissing: true });
82
- outDir = join(process.cwd(), outputPath);
65
+ const spinner = ora('Pulling rule…').start();
66
+ try {
67
+ const filename = await pullRuleToFile(targetId, {
68
+ global: options?.global,
69
+ useSymlink: !options?.copy, // Use symlink unless --copy flag is set
70
+ });
71
+ spinner.succeed(chalk.green('Pulled rule'));
72
+ console.log(chalk.dim(filename));
73
+ if (options?.copy) {
74
+ console.log(chalk.dim('Copied as file (not a symlink)'));
75
+ }
76
+ else {
77
+ console.log(chalk.dim('Created symbolic link to cached rule'));
78
+ }
79
+ if (options?.global) {
80
+ console.log(chalk.dim('Installed globally for all projects'));
81
+ }
83
82
  }
84
- mkdirSync(outDir, { recursive: true });
85
- const filename = join(outDir, ruleFilename(rule.title, rule.id));
86
- const content = `# ${rule.title}\n\n${rule.description}\n\n${rule.body}\n`;
87
- writeFileSync(filename, content);
88
- console.log(chalk.green('Wrote'), filename);
89
- if (options?.global) {
90
- console.log(chalk.dim('Installed globally for all projects'));
83
+ catch (error) {
84
+ spinner.fail(chalk.red('Failed to pull rule'));
85
+ console.error(chalk.red(error.message));
86
+ process.exit(1);
91
87
  }
92
88
  };
93
89
  export const runRulesPush = async (file) => {
@@ -1,5 +1,6 @@
1
1
  export declare const runSolutionsSearch: (query?: string) => Promise<void>;
2
2
  export declare const runSolutionsPull: (id?: string, options?: {
3
3
  global?: boolean;
4
+ copy?: boolean;
4
5
  }) => Promise<void>;
5
6
  export declare const runSolutionsPush: (file?: string) => Promise<void>;
@@ -1,13 +1,9 @@
1
1
  import inquirer from 'inquirer';
2
2
  import ora from 'ora';
3
3
  import chalk from 'chalk';
4
- import { mkdirSync, writeFileSync } from 'fs';
5
- import { join } from 'path';
6
- import { homedir } from 'os';
7
4
  import { loadCredentials } from '../auth/config.js';
8
- import { getProjectConfig } from '../auth/project-config.js';
9
5
  import { searchRules, fetchRules, getRuleById, insertRule } from '../api/client.js';
10
- import { solutionFilename } from '../lib/slug.js';
6
+ import { pullRuleToFile } from '../lib/rule-file-ops.js';
11
7
  export const runSolutionsSearch = async (query) => {
12
8
  if (!loadCredentials()) {
13
9
  console.error(chalk.red('Not logged in. Run bitcompass login.'));
@@ -54,28 +50,28 @@ export const runSolutionsPull = async (id, options) => {
54
50
  ]);
55
51
  targetId = choice.id;
56
52
  }
57
- const rule = await getRuleById(targetId);
58
- if (!rule) {
59
- console.error(chalk.red('Solution not found.'));
60
- process.exit(1);
61
- }
62
- let outDir;
63
- if (options?.global) {
64
- // Use global location: ~/.cursor/rules/
65
- outDir = join(homedir(), '.cursor', 'rules');
66
- }
67
- else {
68
- // Use project config (default behavior)
69
- const { outputPath } = getProjectConfig({ warnIfMissing: true });
70
- outDir = join(process.cwd(), outputPath);
53
+ const spinner = ora('Pulling solution…').start();
54
+ try {
55
+ const filename = await pullRuleToFile(targetId, {
56
+ global: options?.global,
57
+ useSymlink: !options?.copy, // Use symlink unless --copy flag is set
58
+ });
59
+ spinner.succeed(chalk.green('Pulled solution'));
60
+ console.log(chalk.dim(filename));
61
+ if (options?.copy) {
62
+ console.log(chalk.dim('Copied as file (not a symlink)'));
63
+ }
64
+ else {
65
+ console.log(chalk.dim('Created symbolic link to cached solution'));
66
+ }
67
+ if (options?.global) {
68
+ console.log(chalk.dim('Installed globally for all projects'));
69
+ }
71
70
  }
72
- mkdirSync(outDir, { recursive: true });
73
- const filename = join(outDir, solutionFilename(rule.title, rule.id));
74
- const content = `# ${rule.title}\n\n${rule.description}\n\n## Solution\n\n${rule.body}\n`;
75
- writeFileSync(filename, content);
76
- console.log(chalk.green('Wrote'), filename);
77
- if (options?.global) {
78
- console.log(chalk.dim('Installed globally for all projects'));
71
+ catch (error) {
72
+ spinner.fail(chalk.red('Failed to pull solution'));
73
+ console.error(chalk.red(error.message));
74
+ process.exit(1);
79
75
  }
80
76
  };
81
77
  export const runSolutionsPush = async (file) => {
package/dist/index.js CHANGED
@@ -55,8 +55,9 @@ rules.command('search [query]').description('Search rules').action((query) => ru
55
55
  rules.command('list').description('List rules').action(() => runRulesList().catch(handleErr));
56
56
  rules
57
57
  .command('pull [id]')
58
- .description('Pull a rule by ID or choose from list')
58
+ .description('Pull a rule by ID or choose from list (creates symbolic link by default)')
59
59
  .option('-g, --global', 'Install globally to ~/.cursor/rules/ for all projects')
60
+ .option('--copy', 'Copy file instead of creating symbolic link')
60
61
  .action((id, options) => runRulesPull(id, options).catch(handleErr));
61
62
  rules.command('push [file]').description('Push a rule (file or interactive)').action((file) => runRulesPush(file).catch(handleErr));
62
63
  // solutions
@@ -64,8 +65,9 @@ const solutions = program.command('solutions').description('Manage solutions');
64
65
  solutions.command('search [query]').description('Search solutions').action((query) => runSolutionsSearch(query).catch(handleErr));
65
66
  solutions
66
67
  .command('pull [id]')
67
- .description('Pull a solution by ID or choose from list')
68
+ .description('Pull a solution by ID or choose from list (creates symbolic link by default)')
68
69
  .option('-g, --global', 'Install globally to ~/.cursor/rules/ for all projects')
70
+ .option('--copy', 'Copy file instead of creating symbolic link')
69
71
  .action((id, options) => runSolutionsPull(id, options).catch(handleErr));
70
72
  solutions.command('push [file]').description('Push a solution (file or interactive)').action((file) => runSolutionsPush(file).catch(handleErr));
71
73
  // mcp
@@ -2,7 +2,7 @@ export type TimeFrame = 'day' | 'week' | 'month';
2
2
  export interface RepoSummary {
3
3
  remote_url: string;
4
4
  branch: string;
5
- repo_path: string;
5
+ repo_path?: string;
6
6
  }
7
7
  export interface GitCommitInfo {
8
8
  hash: string;
@@ -28,7 +28,8 @@ export interface PeriodBounds {
28
28
  */
29
29
  export declare const getRepoRoot: (cwd: string) => string | null;
30
30
  /**
31
- * Get a short summary of the repo: remote URL, current branch, path.
31
+ * Get a short summary of the repo: remote URL, current branch.
32
+ * Note: repo_path is no longer included to avoid storing absolute paths.
32
33
  */
33
34
  export declare const getRepoSummary: (repoRoot: string) => RepoSummary;
34
35
  /**
@@ -24,7 +24,8 @@ export const getRepoRoot = (cwd) => {
24
24
  }
25
25
  };
26
26
  /**
27
- * Get a short summary of the repo: remote URL, current branch, path.
27
+ * Get a short summary of the repo: remote URL, current branch.
28
+ * Note: repo_path is no longer included to avoid storing absolute paths.
28
29
  */
29
30
  export const getRepoSummary = (repoRoot) => {
30
31
  const remoteUrl = exec('git remote get-url origin', repoRoot) || '';
@@ -32,7 +33,6 @@ export const getRepoSummary = (repoRoot) => {
32
33
  return {
33
34
  remote_url: remoteUrl,
34
35
  branch,
35
- repo_path: repoRoot,
36
36
  };
37
37
  };
38
38
  /**
@@ -0,0 +1,14 @@
1
+ import type { Rule } from '../types.js';
2
+ /**
3
+ * Gets the cache directory for rules (~/.bitcompass/cache/rules/)
4
+ */
5
+ export declare const getCacheDir: () => string;
6
+ /**
7
+ * Gets the cached file path for a rule by ID
8
+ */
9
+ export declare const getCachedRulePath: (rule: Rule) => string;
10
+ /**
11
+ * Ensures a rule is cached. Downloads and caches it if not present or outdated.
12
+ * Returns the path to the cached file.
13
+ */
14
+ export declare const ensureRuleCached: (id: string) => Promise<string>;
@@ -0,0 +1,58 @@
1
+ import { existsSync, mkdirSync, writeFileSync, statSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { getConfigDir } from '../auth/config.js';
4
+ import { getRuleById } from '../api/client.js';
5
+ import { ruleFilename, solutionFilename } from './slug.js';
6
+ /**
7
+ * Gets the cache directory for rules (~/.bitcompass/cache/rules/)
8
+ */
9
+ export const getCacheDir = () => {
10
+ const cacheDir = join(getConfigDir(), 'cache', 'rules');
11
+ if (!existsSync(cacheDir)) {
12
+ mkdirSync(cacheDir, { mode: 0o755, recursive: true });
13
+ }
14
+ return cacheDir;
15
+ };
16
+ /**
17
+ * Gets the cached file path for a rule by ID
18
+ */
19
+ export const getCachedRulePath = (rule) => {
20
+ const cacheDir = getCacheDir();
21
+ const filename = rule.kind === 'solution'
22
+ ? solutionFilename(rule.title, rule.id)
23
+ : ruleFilename(rule.title, rule.id);
24
+ return join(cacheDir, `${rule.id}-${filename}`);
25
+ };
26
+ /**
27
+ * Ensures a rule is cached. Downloads and caches it if not present or outdated.
28
+ * Returns the path to the cached file.
29
+ */
30
+ export const ensureRuleCached = async (id) => {
31
+ const rule = await getRuleById(id);
32
+ if (!rule) {
33
+ throw new Error(`Rule or solution with ID ${id} not found.`);
34
+ }
35
+ const cachedPath = getCachedRulePath(rule);
36
+ const needsUpdate = !existsSync(cachedPath) || isCacheOutdated(cachedPath, rule);
37
+ 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`;
41
+ writeFileSync(cachedPath, content, 'utf-8');
42
+ }
43
+ return cachedPath;
44
+ };
45
+ /**
46
+ * Checks if the cached file is outdated compared to the rule's updated_at timestamp
47
+ */
48
+ const isCacheOutdated = (cachedPath, rule) => {
49
+ try {
50
+ const stats = statSync(cachedPath);
51
+ const cacheTime = stats.mtime.getTime();
52
+ const ruleTime = new Date(rule.updated_at).getTime();
53
+ return ruleTime > cacheTime;
54
+ }
55
+ catch {
56
+ return true; // If we can't read the file, consider it outdated
57
+ }
58
+ };
@@ -3,9 +3,12 @@ export interface PullRuleOptions {
3
3
  global?: boolean;
4
4
  /** Custom output path (overrides project config and global) */
5
5
  outputPath?: string;
6
+ /** Use symbolic links instead of copying files (default: true) */
7
+ useSymlink?: boolean;
6
8
  }
7
9
  /**
8
- * Pulls a rule or solution to a file. Returns the file path where it was written.
10
+ * Pulls a rule or solution to a file using symbolic links (like Bun init).
11
+ * Returns the file path where it was written/linked.
9
12
  * Throws if rule not found or if authentication is required.
10
13
  */
11
14
  export declare const pullRuleToFile: (id: string, options?: PullRuleOptions) => Promise<string>;
@@ -1,14 +1,19 @@
1
- import { mkdirSync, writeFileSync } from 'fs';
2
- import { join } from 'path';
1
+ import { existsSync, mkdirSync, symlinkSync, unlinkSync, writeFileSync, lstatSync } from 'fs';
2
+ import { join, relative, dirname } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { getRuleById } from '../api/client.js';
5
5
  import { getProjectConfig } from '../auth/project-config.js';
6
6
  import { ruleFilename, solutionFilename } from './slug.js';
7
+ import { ensureRuleCached } from './rule-cache.js';
7
8
  /**
8
- * Pulls a rule or solution to a file. Returns the file path where it was written.
9
+ * Pulls a rule or solution to a file using symbolic links (like Bun init).
10
+ * Returns the file path where it was written/linked.
9
11
  * Throws if rule not found or if authentication is required.
10
12
  */
11
13
  export const pullRuleToFile = async (id, options = {}) => {
14
+ const useSymlink = options.useSymlink !== false; // Default to true
15
+ // Ensure rule is cached in central location
16
+ const cachedPath = await ensureRuleCached(id);
12
17
  const rule = await getRuleById(id);
13
18
  if (!rule) {
14
19
  throw new Error(`Rule or solution with ID ${id} not found.`);
@@ -31,9 +36,41 @@ export const pullRuleToFile = async (id, options = {}) => {
31
36
  const filename = rule.kind === 'solution'
32
37
  ? join(outDir, solutionFilename(rule.title, rule.id))
33
38
  : join(outDir, ruleFilename(rule.title, rule.id));
34
- const content = rule.kind === 'solution'
35
- ? `# ${rule.title}\n\n${rule.description}\n\n## Solution\n\n${rule.body}\n`
36
- : `# ${rule.title}\n\n${rule.description}\n\n${rule.body}\n`;
37
- writeFileSync(filename, content);
39
+ // Remove existing file/symlink if it exists
40
+ if (existsSync(filename)) {
41
+ try {
42
+ const stats = lstatSync(filename);
43
+ if (stats.isSymbolicLink() || stats.isFile()) {
44
+ unlinkSync(filename);
45
+ }
46
+ }
47
+ catch {
48
+ // Ignore errors when removing
49
+ }
50
+ }
51
+ if (useSymlink) {
52
+ // Create symbolic link to cached file
53
+ // Use relative path for portability
54
+ const relativePath = relative(dirname(filename), cachedPath);
55
+ try {
56
+ symlinkSync(relativePath, filename);
57
+ }
58
+ catch (error) {
59
+ // Fallback to absolute path if relative fails (e.g., on Windows or cross-filesystem)
60
+ if (error.code === 'ENOENT' || error.code === 'EXDEV') {
61
+ symlinkSync(cachedPath, filename);
62
+ }
63
+ else {
64
+ throw error;
65
+ }
66
+ }
67
+ }
68
+ else {
69
+ // Fallback: copy file content (for compatibility or when symlinks aren't desired)
70
+ const content = rule.kind === 'solution'
71
+ ? `# ${rule.title}\n\n${rule.description}\n\n## Solution\n\n${rule.body}\n`
72
+ : `# ${rule.title}\n\n${rule.description}\n\n${rule.body}\n`;
73
+ writeFileSync(filename, content);
74
+ }
38
75
  return filename;
39
76
  };
package/dist/types.d.ts CHANGED
@@ -10,6 +10,7 @@ export interface Rule {
10
10
  technologies?: string[];
11
11
  user_id: string;
12
12
  author_display_name?: string | null;
13
+ version?: string | null;
13
14
  created_at: string;
14
15
  updated_at: string;
15
16
  }
@@ -21,6 +22,7 @@ export interface RuleInsert {
21
22
  context?: string | null;
22
23
  examples?: string[];
23
24
  technologies?: string[];
25
+ version?: string;
24
26
  }
25
27
  export interface StoredCredentials {
26
28
  access_token: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bitcompass",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "BitCompass CLI - rules, solutions, and MCP server",
5
5
  "type": "module",
6
6
  "bin": {