@prustogi/buddy 0.3.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.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@prustogi/buddy",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "version": "0.3.0",
7
+ "description": "A friendly onboarding agent for GitHub Copilot CLI and Claude Code CLI. Helps newcomers understand any code repository. Stores portable, git-friendly knowledge in .buddy/.",
8
+ "keywords": [
9
+ "copilot",
10
+ "claude",
11
+ "cli",
12
+ "onboarding",
13
+ "documentation",
14
+ "agent",
15
+ "buddy"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "Buddy Contributors",
19
+ "type": "module",
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "bin": {
24
+ "buddy": "bin/buddy.js"
25
+ },
26
+ "main": "src/lib/index.js",
27
+ "files": [
28
+ "bin/",
29
+ "src/",
30
+ "agents/",
31
+ "templates/",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "scripts": {
36
+ "test": "node --test test/*.test.js",
37
+ "start": "node bin/buddy.js"
38
+ },
39
+ "dependencies": {
40
+ "commander": "^12.1.0"
41
+ }
42
+ }
@@ -0,0 +1,138 @@
1
+ import { existsSync, mkdirSync, copyFileSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { agentPromptPath, claudeAgentPromptPath, findRepoRoot } from '../lib/paths.js';
5
+
6
+ // Install destinations auto-discovered by each CLI:
7
+ // Copilot CLI repo: <repoRoot>/.github/agents/buddy.md
8
+ // Copilot CLI user: ~/.copilot/agents/buddy.md
9
+ // Claude Code repo: <repoRoot>/.claude/agents/buddy.md
10
+ // Claude Code user: ~/.claude/agents/buddy.md
11
+
12
+ function destForCopilot(scope, repoRoot) {
13
+ if (scope === 'user') return join(homedir(), '.copilot', 'agents', 'buddy.md');
14
+ return join(repoRoot, '.github', 'agents', 'buddy.md');
15
+ }
16
+
17
+ function destForClaude(scope, repoRoot) {
18
+ if (scope === 'user') return join(homedir(), '.claude', 'agents', 'buddy.md');
19
+ return join(repoRoot, '.claude', 'agents', 'buddy.md');
20
+ }
21
+
22
+ function installTo(src, dest, force) {
23
+ if (!existsSync(src)) throw new Error(`agent prompt not found at ${src}`);
24
+ const destDir = join(dest, '..');
25
+ if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
26
+
27
+ if (existsSync(dest)) {
28
+ if (!force) {
29
+ const a = readFileSync(src, 'utf8');
30
+ const b = readFileSync(dest, 'utf8');
31
+ if (a === b) return { dest, action: 'already-installed' };
32
+ return { dest, action: 'exists-different', src };
33
+ }
34
+ copyFileSync(src, dest);
35
+ return { dest, action: 'overwritten' };
36
+ }
37
+ copyFileSync(src, dest);
38
+ return { dest, action: 'created' };
39
+ }
40
+
41
+ export function installAgent({ scope = 'repo', force = false, repoRoot } = {}) {
42
+ const root = repoRoot || findRepoRoot();
43
+ const dest = destForCopilot(scope, root);
44
+ return installTo(agentPromptPath(), dest, force);
45
+ }
46
+
47
+ export function installClaudeAgent({ scope = 'repo', force = false, repoRoot } = {}) {
48
+ const root = repoRoot || findRepoRoot();
49
+ const dest = destForClaude(scope, root);
50
+ return installTo(claudeAgentPromptPath(), dest, force);
51
+ }
52
+
53
+ function printInstallResult(result, label) {
54
+ if (result.action === 'already-installed') {
55
+ console.log(`✓ ${label} agent already installed at ${result.dest}`);
56
+ } else if (result.action === 'exists-different') {
57
+ console.log(`! ${label} agent already exists at ${result.dest} (different version).`);
58
+ console.log(' Re-run with --force to overwrite.');
59
+ return false;
60
+ } else {
61
+ const verb = result.action === 'overwritten' ? 'Overwrote' : 'Installed';
62
+ console.log(`🤝 ${verb} ${label} agent at ${result.dest}`);
63
+ }
64
+ return true;
65
+ }
66
+
67
+ function listAgents(repoRoot) {
68
+ const root = repoRoot || findRepoRoot();
69
+ const locations = [
70
+ { label: 'Copilot CLI (repo)', path: destForCopilot('repo', root) },
71
+ { label: 'Copilot CLI (user)', path: destForCopilot('user', root) },
72
+ { label: 'Claude Code (repo)', path: destForClaude('repo', root) },
73
+ { label: 'Claude Code (user)', path: destForClaude('user', root) },
74
+ ];
75
+ console.log('Buddy agent locations:');
76
+ for (const loc of locations) {
77
+ const status = existsSync(loc.path) ? '✓ installed' : '✗ not installed';
78
+ console.log(` ${status} ${loc.label}`);
79
+ console.log(` ${loc.path}`);
80
+ }
81
+ }
82
+
83
+ export async function agentCommand(subcommand, opts = {}) {
84
+ const sub = subcommand || 'install';
85
+ const scope = opts.user ? 'user' : 'repo';
86
+ const repoRoot = findRepoRoot();
87
+
88
+ if (sub === 'path') {
89
+ console.log('Copilot CLI agent:', agentPromptPath());
90
+ console.log('Claude Code agent:', claudeAgentPromptPath());
91
+ return;
92
+ }
93
+
94
+ if (sub === 'list') {
95
+ listAgents(repoRoot);
96
+ return;
97
+ }
98
+
99
+ if (sub === 'install') {
100
+ const forClaude = opts.claude || opts.all;
101
+ const forCopilot = !opts.claude || opts.all;
102
+ let anyFailed = false;
103
+
104
+ if (forCopilot) {
105
+ const result = installAgent({ scope, force: !!opts.force, repoRoot });
106
+ const ok = printInstallResult(result, 'Copilot CLI');
107
+ if (!ok) anyFailed = true;
108
+ }
109
+
110
+ if (forClaude) {
111
+ const result = installClaudeAgent({ scope, force: !!opts.force, repoRoot });
112
+ const ok = printInstallResult(result, 'Claude Code');
113
+ if (!ok) anyFailed = true;
114
+ }
115
+
116
+ if (anyFailed) {
117
+ process.exitCode = 1;
118
+ return;
119
+ }
120
+
121
+ console.log('');
122
+ if (forCopilot && !forClaude) {
123
+ console.log('Next: launch Copilot CLI in this repo and run:');
124
+ console.log(' /agent (then pick "buddy")');
125
+ } else if (forClaude && !forCopilot) {
126
+ console.log('Next: launch Claude Code in this repo and run:');
127
+ console.log(' @buddy (or mention Buddy naturally in your prompt)');
128
+ } else {
129
+ console.log('Next steps:');
130
+ console.log(' Copilot CLI: /agent (then pick "buddy")');
131
+ console.log(' Claude Code: @buddy (or mention Buddy naturally in your prompt)');
132
+ }
133
+ return;
134
+ }
135
+
136
+ console.error(`buddy: unknown agent subcommand "${sub}". Use: install [--claude] [--all] [--user] [--force] | list | path`);
137
+ process.exit(1);
138
+ }
@@ -0,0 +1,101 @@
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { findRepoRoot, buddyDir, templatesDir, isDir } from '../lib/paths.js';
4
+ import { copyTree } from '../lib/scaffold.js';
5
+ import { readManifest, writeManifest, defaultManifest } from '../lib/manifest.js';
6
+ import { hasGit, headCommit } from '../lib/git.js';
7
+ import { openFile } from '../lib/opener.js';
8
+ import { installAgent, installClaudeAgent } from './agent.js';
9
+
10
+ export async function initCommand(opts) {
11
+ const repoRoot = findRepoRoot();
12
+ const buddy = buddyDir(repoRoot);
13
+ const homePage = join(buddy, 'README_FOR_HUMANS.md');
14
+ const alreadyExisted = isDir(buddy);
15
+
16
+ if (alreadyExisted && !opts.force) {
17
+ console.log(`✓ .buddy/ already exists at ${buddy}`);
18
+ console.log(' Treating it as the source of truth. Nothing to scaffold.');
19
+ maybeInstallAgent(repoRoot, opts);
20
+ autoOpenHome(homePage, opts);
21
+ return;
22
+ }
23
+
24
+ if (!isDir(buddy)) mkdirSync(buddy, { recursive: true });
25
+
26
+ const { created, skipped } = copyTree(templatesDir(), buddy);
27
+
28
+ const manifest = readManifest(buddy) || defaultManifest();
29
+ manifest.last_run_timestamp = new Date().toISOString();
30
+ if (hasGit(repoRoot)) {
31
+ manifest.last_indexed_commit = headCommit(repoRoot);
32
+ }
33
+ manifest.key_outputs_updated = ['init'];
34
+ writeManifest(buddy, manifest);
35
+
36
+ console.log(`🎉 Buddy initialized at ${buddy}`);
37
+ console.log(` Files created: ${created.length}, skipped (already present): ${skipped.length}`);
38
+ if (manifest.last_indexed_commit) {
39
+ console.log(` Indexed commit: ${manifest.last_indexed_commit.slice(0, 8)}`);
40
+ } else {
41
+ console.log(' (No git repo detected — fine. Buddy will fall back to file mtimes.)');
42
+ }
43
+
44
+ maybeInstallAgent(repoRoot, opts);
45
+
46
+ console.log('');
47
+ console.log('Next steps:');
48
+ console.log(' Copilot CLI: /agent → pick "buddy" → "Scan this repo and fill in .buddy/"');
49
+ console.log(' Claude Code: @buddy → "Scan this repo and fill in .buddy/"');
50
+
51
+ autoOpenHome(homePage, opts);
52
+ }
53
+
54
+ function maybeInstallAgent(repoRoot, opts) {
55
+ if (opts.installAgent === false) return;
56
+ const scope = opts.userAgent ? 'user' : 'repo';
57
+
58
+ // Install for Copilot CLI
59
+ try {
60
+ const result = installAgent({ scope, repoRoot });
61
+ if (result.action === 'already-installed') {
62
+ console.log(`✓ Copilot CLI agent already installed at ${result.dest}`);
63
+ } else if (result.action === 'exists-different') {
64
+ console.log(`! Copilot CLI agent at ${result.dest} differs — run "buddy agent install --force" to update.`);
65
+ } else {
66
+ console.log(`🤝 Installed Copilot CLI agent at ${result.dest}`);
67
+ }
68
+ } catch (err) {
69
+ console.log(`! Could not install Copilot CLI agent: ${err.message}`);
70
+ }
71
+
72
+ // Install for Claude Code
73
+ try {
74
+ const result = installClaudeAgent({ scope, repoRoot });
75
+ if (result.action === 'already-installed') {
76
+ console.log(`✓ Claude Code agent already installed at ${result.dest}`);
77
+ } else if (result.action === 'exists-different') {
78
+ console.log(`! Claude Code agent at ${result.dest} differs — run "buddy agent install --claude --force" to update.`);
79
+ } else {
80
+ console.log(`🤝 Installed Claude Code agent at ${result.dest}`);
81
+ }
82
+ } catch (err) {
83
+ console.log(`! Could not install Claude Code agent: ${err.message}`);
84
+ }
85
+ }
86
+
87
+ function autoOpenHome(homePage, opts) {
88
+ if (opts.open === false) {
89
+ console.log(`\n📖 Home page: ${homePage}`);
90
+ return;
91
+ }
92
+ const result = openFile(homePage, { silent: true });
93
+ if (result.opened) {
94
+ console.log(`\n📖 Opened home page: ${homePage}`);
95
+ } else {
96
+ console.log(`\n📖 Home page: ${homePage}`);
97
+ if (result.reason && result.reason !== 'BUDDY_NO_OPEN=1') {
98
+ console.log(` (Could not auto-open: ${result.reason})`);
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,70 @@
1
+ import { readFileSync, writeFileSync, existsSync, appendFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { findRepoRoot, buddyDir, isDir } from '../lib/paths.js';
4
+ import { redactUrl } from '../lib/redact.js';
5
+ import { readManifest, writeManifest, defaultManifest } from '../lib/manifest.js';
6
+
7
+ export async function linkCommand(rawUrl, opts) {
8
+ const repoRoot = findRepoRoot();
9
+ const buddy = buddyDir(repoRoot);
10
+
11
+ if (!isDir(buddy)) {
12
+ console.error('buddy: no .buddy/ here. Run "buddy init" first.');
13
+ process.exit(1);
14
+ }
15
+
16
+ const { url, redacted, warnings } = redactUrl(rawUrl);
17
+ for (const w of warnings) console.log(`⚠️ ${w}`);
18
+
19
+ const entry = {
20
+ title: opts.title || inferTitle(url),
21
+ url,
22
+ description: opts.desc || '',
23
+ tags: (opts.tags || '').split(',').map((s) => s.trim()).filter(Boolean),
24
+ relevance: opts.relevance || 'helpful',
25
+ added_by: process.env.USER || process.env.USERNAME || 'unknown',
26
+ added_at: new Date().toISOString(),
27
+ redacted,
28
+ };
29
+
30
+ // Update INDEX/links.json
31
+ const linksJsonPath = join(buddy, 'INDEX', 'links.json');
32
+ const existing = existsSync(linksJsonPath)
33
+ ? safeJson(readFileSync(linksJsonPath, 'utf8'), { links: [] })
34
+ : { links: [] };
35
+ existing.links.push(entry);
36
+ writeFileSync(linksJsonPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
37
+
38
+ // Append to LINKS.md (human-friendly)
39
+ const linksMd = join(buddy, 'LINKS.md');
40
+ const block =
41
+ `\n### ${entry.title}\n` +
42
+ `- **URL:** ${entry.url}\n` +
43
+ `- **Why it matters:** ${entry.description || '(add a short note)'}\n` +
44
+ `- **Tags:** ${entry.tags.join(', ') || '(none)'}\n` +
45
+ `- **Relevance:** ${entry.relevance}\n` +
46
+ `- **Added:** ${entry.added_at} by ${entry.added_by}\n`;
47
+ appendFileSync(linksMd, block, 'utf8');
48
+
49
+ // Update manifest timestamp
50
+ const manifest = readManifest(buddy) || defaultManifest();
51
+ manifest.last_link_update_timestamp = entry.added_at;
52
+ writeManifest(buddy, manifest);
53
+
54
+ console.log(`📎 Saved link: ${entry.title}`);
55
+ console.log(` ${entry.url}`);
56
+ console.log(' (Note: Buddy stores link metadata only — it has not read the linked page.)');
57
+ }
58
+
59
+ function safeJson(text, fallback) {
60
+ try { return JSON.parse(text); } catch { return fallback; }
61
+ }
62
+
63
+ function inferTitle(url) {
64
+ try {
65
+ const u = new URL(url);
66
+ return `${u.hostname}${u.pathname}`.replace(/\/$/, '') || u.hostname;
67
+ } catch {
68
+ return url.slice(0, 60);
69
+ }
70
+ }
@@ -0,0 +1,66 @@
1
+ import { join } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { findRepoRoot, buddyDir, isDir } from '../lib/paths.js';
4
+ import { openFile } from '../lib/opener.js';
5
+
6
+ const ALIASES = {
7
+ home: 'README_FOR_HUMANS.md',
8
+ readme: 'README_FOR_HUMANS.md',
9
+ 'getting-started': 'GETTING_STARTED.md',
10
+ setup: 'GETTING_STARTED.md',
11
+ architecture: 'ARCHITECTURE.md',
12
+ arch: 'ARCHITECTURE.md',
13
+ stack: 'TECH_STACK.md',
14
+ 'tech-stack': 'TECH_STACK.md',
15
+ integrations: 'INTEGRATIONS.md',
16
+ changelog: 'CHANGELOG_SUMMARY.md',
17
+ links: 'LINKS.md',
18
+ map: 'MAP/repo_map.md',
19
+ entry: 'MAP/entry_points.md',
20
+ 'entry-points': 'MAP/entry_points.md',
21
+ flow: 'MAP/data_flow.md',
22
+ 'data-flow': 'MAP/data_flow.md',
23
+ questions: 'NOTES/open_questions.md',
24
+ assumptions: 'NOTES/assumptions.md',
25
+ };
26
+
27
+ export async function openCommand(doc, opts) {
28
+ const repoRoot = findRepoRoot();
29
+ const buddy = buddyDir(repoRoot);
30
+
31
+ if (!isDir(buddy)) {
32
+ console.error(`buddy: no .buddy/ found at ${buddy}`);
33
+ console.error(' Run "buddy init" first.');
34
+ process.exit(1);
35
+ }
36
+
37
+ const target = doc ? resolveDoc(doc) : 'README_FOR_HUMANS.md';
38
+ const filePath = join(buddy, target);
39
+
40
+ if (!existsSync(filePath)) {
41
+ console.error(`buddy: not found in .buddy/ — ${target}`);
42
+ process.exit(1);
43
+ }
44
+
45
+ if (opts.open === false) {
46
+ console.log(filePath);
47
+ return;
48
+ }
49
+
50
+ const result = openFile(filePath, { silent: true });
51
+ if (result.opened) {
52
+ console.log(`📖 Opened ${target}`);
53
+ } else {
54
+ console.log(filePath);
55
+ if (result.reason && result.reason !== 'BUDDY_NO_OPEN=1') {
56
+ console.log(`(Could not auto-open: ${result.reason})`);
57
+ }
58
+ }
59
+ }
60
+
61
+ function resolveDoc(name) {
62
+ const key = name.toLowerCase().replace(/\.md$/, '');
63
+ if (ALIASES[key]) return ALIASES[key];
64
+ // Allow direct path inside .buddy/
65
+ return name;
66
+ }
@@ -0,0 +1,65 @@
1
+ import { findRepoRoot, buddyDir, isDir } from '../lib/paths.js';
2
+ import { readManifest } from '../lib/manifest.js';
3
+ import { hasGit, headCommit, changedFilesSince, workingTreeChanges } from '../lib/git.js';
4
+
5
+ // Heuristics: which doc(s) might be stale based on which files changed.
6
+ const RULES = [
7
+ { match: /(^|\/)package\.json$|^requirements\.txt$|^pyproject\.toml$|^go\.mod$|^Cargo\.toml$|^pom\.xml$|^build\.gradle/, doc: 'TECH_STACK.md' },
8
+ { match: /^README\.md$|^docs?\//i, doc: 'README_FOR_HUMANS.md' },
9
+ { match: /^Dockerfile$|docker-compose|\.env|config\//i, doc: 'INTEGRATIONS.md' },
10
+ { match: /^src\//, doc: 'MAP/repo_map.md' },
11
+ { match: /(server|main|index|app|cli)\.(js|ts|py|go|rs|java)$/i, doc: 'MAP/entry_points.md' },
12
+ { match: /(routes?|controllers?|handlers?|api)\//i, doc: 'MAP/data_flow.md' },
13
+ { match: /\.github\/|Makefile|justfile/i, doc: 'GETTING_STARTED.md' },
14
+ ];
15
+
16
+ export async function precheckCommand() {
17
+ const repoRoot = findRepoRoot();
18
+ const buddy = buddyDir(repoRoot);
19
+
20
+ if (!isDir(buddy)) {
21
+ console.log('❌ No .buddy/ here. Run "buddy init" first.');
22
+ return;
23
+ }
24
+ if (!hasGit(repoRoot)) {
25
+ console.log('⚠️ No git detected — precheck needs git history.');
26
+ return;
27
+ }
28
+
29
+ const manifest = readManifest(buddy);
30
+ const last = manifest && manifest.last_indexed_commit;
31
+ if (!last) {
32
+ console.log('💡 No prior index. Ask Buddy to scan this repo first.');
33
+ return;
34
+ }
35
+
36
+ const head = headCommit(repoRoot);
37
+ const committed = changedFilesSince(last, repoRoot);
38
+ const working = workingTreeChanges(repoRoot);
39
+ const allPaths = [
40
+ ...committed.map((c) => c.path),
41
+ ...working.map((w) => w.path),
42
+ ];
43
+
44
+ console.log(`🔍 Files changed since .buddy was last updated (commit ${last.slice(0, 8)} → ${head.slice(0, 8)}):`);
45
+ if (allPaths.length === 0) {
46
+ console.log(' (none)');
47
+ console.log('\n✅ .buddy/ should still be current.');
48
+ return;
49
+ }
50
+ for (const p of allPaths.slice(0, 30)) console.log(` ${p}`);
51
+ if (allPaths.length > 30) console.log(` …and ${allPaths.length - 30} more`);
52
+
53
+ const stale = new Set();
54
+ stale.add('CHANGELOG_SUMMARY.md'); // always
55
+ for (const p of allPaths) {
56
+ for (const rule of RULES) {
57
+ if (rule.match.test(p)) stale.add(rule.doc);
58
+ }
59
+ }
60
+
61
+ console.log('\nLikely-stale Buddy docs:');
62
+ for (const doc of stale) console.log(` .buddy/${doc}`);
63
+
64
+ console.log('\n💡 In Copilot CLI: ask Buddy "update buddy for my changes".');
65
+ }
@@ -0,0 +1,49 @@
1
+ import { findRepoRoot, buddyDir, isDir } from '../lib/paths.js';
2
+ import { readManifest } from '../lib/manifest.js';
3
+ import { hasGit, headCommit, changedFilesSince } from '../lib/git.js';
4
+
5
+ export async function statusCommand() {
6
+ const repoRoot = findRepoRoot();
7
+ const buddy = buddyDir(repoRoot);
8
+
9
+ if (!isDir(buddy)) {
10
+ console.log('❌ No .buddy/ in this repo. Run "buddy init".');
11
+ return;
12
+ }
13
+
14
+ const manifest = readManifest(buddy);
15
+ if (!manifest) {
16
+ console.log('⚠️ .buddy/ exists but manifest.json is missing or unreadable.');
17
+ return;
18
+ }
19
+
20
+ console.log(`📦 Buddy at ${buddy}`);
21
+ console.log(` Last run: ${manifest.last_run_timestamp || '(never)'}`);
22
+ console.log(` Last indexed commit: ${manifest.last_indexed_commit || '(none)'}`);
23
+
24
+ if (!hasGit(repoRoot)) {
25
+ console.log(' (No git in this repo — skipping diff.)');
26
+ return;
27
+ }
28
+
29
+ const head = headCommit(repoRoot);
30
+ console.log(` Current HEAD: ${head ? head.slice(0, 8) : '(unknown)'}`);
31
+
32
+ if (!manifest.last_indexed_commit) {
33
+ console.log('\n💡 Buddy has never indexed this repo. Ask Buddy in Copilot CLI: "scan this repo".');
34
+ return;
35
+ }
36
+
37
+ if (head === manifest.last_indexed_commit) {
38
+ console.log('\n✅ .buddy/ is current with HEAD.');
39
+ return;
40
+ }
41
+
42
+ const changes = changedFilesSince(manifest.last_indexed_commit, repoRoot);
43
+ console.log(`\n🔍 ${changes.length} file(s) changed since last index:`);
44
+ for (const c of changes.slice(0, 20)) {
45
+ console.log(` ${c.status} ${c.path}`);
46
+ }
47
+ if (changes.length > 20) console.log(` …and ${changes.length - 20} more`);
48
+ console.log('\n💡 In Copilot CLI: ask Buddy "update buddy for my changes".');
49
+ }
package/src/lib/git.js ADDED
@@ -0,0 +1,40 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ function runGit(args, cwd) {
4
+ try {
5
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
6
+ } catch {
7
+ return null;
8
+ }
9
+ }
10
+
11
+ export function hasGit(cwd) {
12
+ return runGit(['rev-parse', '--is-inside-work-tree'], cwd) === 'true';
13
+ }
14
+
15
+ export function headCommit(cwd) {
16
+ return runGit(['rev-parse', 'HEAD'], cwd);
17
+ }
18
+
19
+ export function changedFilesSince(commit, cwd) {
20
+ if (!commit) return [];
21
+ const out = runGit(['diff', '--name-status', `${commit}..HEAD`], cwd);
22
+ if (!out) return [];
23
+ return out
24
+ .split('\n')
25
+ .map((line) => {
26
+ const parts = line.split('\t');
27
+ if (parts.length < 2) return null;
28
+ return { status: parts[0].trim(), path: parts.slice(1).join('\t').trim() };
29
+ })
30
+ .filter(Boolean);
31
+ }
32
+
33
+ export function workingTreeChanges(cwd) {
34
+ const out = runGit(['status', '--porcelain'], cwd);
35
+ if (!out) return [];
36
+ return out.split('\n').map((line) => ({
37
+ status: line.slice(0, 2).trim(),
38
+ path: line.slice(3).trim(),
39
+ }));
40
+ }
@@ -0,0 +1,6 @@
1
+ export * from './paths.js';
2
+ export * from './git.js';
3
+ export * from './redact.js';
4
+ export * from './manifest.js';
5
+ export * from './opener.js';
6
+ export * from './scaffold.js';
@@ -0,0 +1,32 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export function manifestPath(buddyRoot) {
5
+ return join(buddyRoot, 'manifest.json');
6
+ }
7
+
8
+ export function readManifest(buddyRoot) {
9
+ const p = manifestPath(buddyRoot);
10
+ if (!existsSync(p)) return null;
11
+ try {
12
+ return JSON.parse(readFileSync(p, 'utf8'));
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ export function writeManifest(buddyRoot, manifest) {
19
+ const p = manifestPath(buddyRoot);
20
+ writeFileSync(p, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
21
+ }
22
+
23
+ export function defaultManifest() {
24
+ return {
25
+ schema_version: 1,
26
+ last_indexed_commit: null,
27
+ last_run_timestamp: new Date().toISOString(),
28
+ files_scanned_count: 0,
29
+ key_outputs_updated: [],
30
+ last_link_update_timestamp: null,
31
+ };
32
+ }
@@ -0,0 +1,44 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { platform } from 'node:os';
4
+
5
+ // Open a file with the OS default app (or $EDITOR/$VISUAL when set).
6
+ // Returns { opened: boolean, command: string|null, reason: string|null }.
7
+ export function openFile(filePath, { silent = false } = {}) {
8
+ if (process.env.BUDDY_NO_OPEN === '1') {
9
+ return { opened: false, command: null, reason: 'BUDDY_NO_OPEN=1' };
10
+ }
11
+
12
+ if (!existsSync(filePath)) {
13
+ return { opened: false, command: null, reason: `File not found: ${filePath}` };
14
+ }
15
+
16
+ const editor = process.env.VISUAL || process.env.EDITOR;
17
+ if (editor) {
18
+ return launch(editor, [filePath], silent);
19
+ }
20
+
21
+ const p = platform();
22
+ if (p === 'win32') {
23
+ // Use cmd /c start so we don't block. Empty string is the window title.
24
+ return launch('cmd', ['/c', 'start', '', filePath], silent);
25
+ }
26
+ if (p === 'darwin') {
27
+ return launch('open', [filePath], silent);
28
+ }
29
+ return launch('xdg-open', [filePath], silent);
30
+ }
31
+
32
+ function launch(cmd, args, silent) {
33
+ try {
34
+ const child = spawn(cmd, args, {
35
+ detached: true,
36
+ stdio: silent ? 'ignore' : 'inherit',
37
+ shell: false,
38
+ });
39
+ child.unref();
40
+ return { opened: true, command: `${cmd} ${args.join(' ')}`, reason: null };
41
+ } catch (err) {
42
+ return { opened: false, command: cmd, reason: err.message };
43
+ }
44
+ }