@omnitype-code/cli 0.1.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/src/daemon.ts ADDED
@@ -0,0 +1,171 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { execFileSync } from 'child_process';
4
+ import chokidar from 'chokidar';
5
+ import chalk from 'chalk';
6
+ import { ApiClient } from './core/ApiClient';
7
+ import { ModelDetector } from './core/ModelDetector';
8
+ import { FileProvenance } from './core/FileProvenance';
9
+ import type { StoredProvenance, LineHashBaseline } from './core/FileProvenance';
10
+ import { extensionIsActiveFor, writeDaemonHeartbeat } from './core/Heartbeat';
11
+ import { UI, COLORS } from './core/UI';
12
+
13
+ const PUSH_INTERVAL_MS = 60_000;
14
+ const IDLE_PUSH_MS = 10_000;
15
+ const IGNORE_DIRS = /[/\\](\.git|node_modules|\.next|dist|build|__pycache__|\.venv|venv)[/\\]/;
16
+ const TEXT_EXTS = new Set([
17
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
18
+ '.py', '.go', '.rs', '.java', '.kt', '.swift', '.c', '.cpp', '.h',
19
+ '.rb', '.php', '.cs', '.ex', '.exs', '.clj', '.scala', '.hs',
20
+ '.html', '.css', '.scss', '.less', '.vue', '.svelte',
21
+ '.json', '.yaml', '.yml', '.toml', '.md', '.mdx', '.txt',
22
+ '.sh', '.bash', '.zsh', '.fish', '.ps1',
23
+ ]);
24
+
25
+ type LineBaseline = LineHashBaseline;
26
+
27
+ function hashLine(s: string): number {
28
+ let h = 0x811c9dc5;
29
+ for (let i = 0; i < s.length; i++) {
30
+ h ^= s.charCodeAt(i);
31
+ h = (h * 0x01000193) >>> 0;
32
+ }
33
+ return h;
34
+ }
35
+
36
+ function toBaseline(content: string): LineBaseline[] {
37
+ return content.split('\n').map(l => ({ hash: hashLine(l), charLen: l.length + 1 }));
38
+ }
39
+
40
+ function gitBranch(repoPath: string): string {
41
+ try {
42
+ return execFileSync('git', ['-C', repoPath, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' }).trim();
43
+ } catch { return 'main'; }
44
+ }
45
+
46
+ function isTextFile(filePath: string): boolean {
47
+ return TEXT_EXTS.has(path.extname(filePath).toLowerCase());
48
+ }
49
+
50
+ export function startDaemon(opts: { watchPath: string; projectName: string; branch?: string }): void {
51
+ const api = new ApiClient();
52
+ const detector = new ModelDetector();
53
+ const baselines = new Map<string, LineBaseline[]>();
54
+ const dirtyFiles = new Set<string>();
55
+ const provenance = new Map<string, FileProvenance>();
56
+
57
+ if (!api.isSignedIn) {
58
+ UI.error('Not signed in. Run: omnitype login');
59
+ process.exit(1);
60
+ }
61
+
62
+ const watchPath = path.resolve(opts.watchPath);
63
+ const projectName = opts.projectName;
64
+ const branch = opts.branch ?? gitBranch(watchPath);
65
+
66
+ console.log(UI.box(
67
+ `${chalk.bold('Project:')} ${chalk.cyan(projectName)}\n` +
68
+ `${chalk.bold('Branch:')} ${chalk.magenta(branch)}\n` +
69
+ `${chalk.bold('Path:')} ${UI.dim(watchPath)}`,
70
+ `${UI.logo()} Sentinel Active`
71
+ ));
72
+
73
+ writeDaemonHeartbeat(watchPath);
74
+ setInterval(() => writeDaemonHeartbeat(watchPath), 10_000);
75
+
76
+ function seedFile(filePath: string): void {
77
+ if (!isTextFile(filePath) || IGNORE_DIRS.test(filePath)) return;
78
+ try {
79
+ const content = fs.readFileSync(filePath, 'utf8');
80
+ baselines.set(filePath, toBaseline(content));
81
+ if (!provenance.has(filePath)) provenance.set(filePath, new FileProvenance(0));
82
+ } catch { /* unreadable */ }
83
+ }
84
+
85
+ function handleChange(filePath: string): void {
86
+ if (!isTextFile(filePath) || IGNORE_DIRS.test(filePath)) return;
87
+
88
+ let content: string;
89
+ try { content = fs.readFileSync(filePath, 'utf8'); }
90
+ catch { return; }
91
+
92
+ const newBaseline = toBaseline(content);
93
+ const oldBaseline = baselines.get(filePath) ?? [];
94
+ baselines.set(filePath, newBaseline);
95
+
96
+ if (oldBaseline.length === newBaseline.length &&
97
+ oldBaseline.every((b, i) => b.hash === newBaseline[i].hash)) return;
98
+
99
+ const detection = detector.detect(filePath);
100
+ const origin: 'ai' | 'user' = detection.tool !== 'unknown' ? 'ai' : 'user';
101
+
102
+ let prov = provenance.get(filePath);
103
+ if (!prov) { prov = new FileProvenance(0); provenance.set(filePath, prov); }
104
+
105
+ const modelId = origin === 'ai' ? prov.internModel(detection.model) : undefined;
106
+ prov.applyLineDiff(oldBaseline, newBaseline, origin, modelId);
107
+ dirtyFiles.add(filePath);
108
+
109
+ const rel = path.relative(watchPath, filePath);
110
+ const added = newBaseline.length - oldBaseline.length;
111
+
112
+ const icon = origin === 'ai' ? chalk.hex(COLORS.ai)('✦') : chalk.hex(COLORS.user)('✎');
113
+ const modelTag = origin === 'ai' ? UI.dim(` (${detection.model})`) : '';
114
+ const diffTag = added !== 0 ? ` ${added > 0 ? '+' : ''}${added} lines` : '';
115
+
116
+ console.log(`${icon} ${chalk.white(rel)}${diffTag}${modelTag}`);
117
+ }
118
+
119
+ let idleTimer: NodeJS.Timeout | null = null;
120
+
121
+ async function flush(): Promise<void> {
122
+ if (dirtyFiles.size === 0) return;
123
+
124
+ if (extensionIsActiveFor(watchPath)) {
125
+ UI.info('Yielding to VS Code extension (active)');
126
+ return;
127
+ }
128
+
129
+ const snapshot: Record<string, StoredProvenance> = {};
130
+ for (const f of dirtyFiles) {
131
+ const prov = provenance.get(f);
132
+ const baseline = baselines.get(f);
133
+ if (prov && baseline) snapshot[path.relative(watchPath, f)] = prov.toStored(baseline);
134
+ }
135
+ dirtyFiles.clear();
136
+ try {
137
+ await api.pushProvenance(projectName, branch, snapshot, { source: 'cli-daemon' });
138
+ UI.success(`Synced ${Object.keys(snapshot).length} file(s) to cloud`);
139
+ } catch (err) {
140
+ UI.error(`Sync failed: ${err}`);
141
+ for (const rel of Object.keys(snapshot)) dirtyFiles.add(path.join(watchPath, rel));
142
+ }
143
+ }
144
+
145
+ function scheduleIdleFlush(): void {
146
+ if (idleTimer) clearTimeout(idleTimer);
147
+ idleTimer = setTimeout(() => { idleTimer = null; flush(); }, IDLE_PUSH_MS);
148
+ }
149
+
150
+ const watcher = chokidar.watch(watchPath, {
151
+ ignoreInitial: false,
152
+ ignored: (p: string) => IGNORE_DIRS.test(p),
153
+ persistent: true,
154
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
155
+ });
156
+
157
+ watcher
158
+ .on('add', (p: string) => seedFile(p))
159
+ .on('change', (p: string) => { handleChange(p); scheduleIdleFlush(); })
160
+ .on('unlink', (p: string) => { baselines.delete(p); provenance.delete(p); dirtyFiles.delete(p); })
161
+ .on('error', (err: Error) => UI.error(`Watcher error: ${err.message}`));
162
+
163
+ setInterval(flush, PUSH_INTERVAL_MS);
164
+
165
+ const cleanup = () => {
166
+ UI.info('Shutting down...');
167
+ flush().finally(() => process.exit(0));
168
+ };
169
+ process.on('SIGINT', cleanup);
170
+ process.on('SIGTERM', cleanup);
171
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Git hook installer for omnitype.
3
+ *
4
+ * Installs a post-commit hook that calls `omnitype commit-scan` after each commit.
5
+ * Non-destructive: appends to existing hooks rather than replacing them.
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import * as os from 'os';
11
+ import { execFileSync } from 'child_process';
12
+ import { ApiClient } from './core/ApiClient';
13
+ import { ModelDetector } from './core/ModelDetector';
14
+ import { FileProvenance } from './core/FileProvenance';
15
+ import type { StoredProvenance, LineHashBaseline } from './core/FileProvenance';
16
+ import { writeNote, buildNote, provenanceToNoteFile } from './core/GitNotes';
17
+
18
+ const MARKER = '# omnitype provenance — do not edit this block';
19
+ const MARKER_END = '# /omnitype';
20
+
21
+ function findGitDir(cwd: string): string | undefined {
22
+ let dir = cwd;
23
+ for (let i = 0; i < 20; i++) {
24
+ const candidate = path.join(dir, '.git');
25
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
26
+ const parent = path.dirname(dir);
27
+ if (parent === dir) return undefined;
28
+ dir = parent;
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ export function installHooks(cwd = process.cwd()): void {
34
+ const gitDir = findGitDir(cwd);
35
+ if (!gitDir) throw new Error('Not inside a git repository.');
36
+
37
+ const hooksDir = path.join(gitDir, 'hooks');
38
+ fs.mkdirSync(hooksDir, { recursive: true });
39
+ const hookFile = path.join(hooksDir, 'post-commit');
40
+
41
+ // Build hook snippet
42
+ const omnityeBin = process.execPath === process.argv[0]
43
+ ? 'omnitype'
44
+ : `node "${process.argv[1]}"`;
45
+
46
+ const snippet = [
47
+ MARKER,
48
+ `${omnityeBin} commit-scan --repo "$(git rev-parse --show-toplevel)"`,
49
+ MARKER_END,
50
+ '',
51
+ ].join('\n');
52
+
53
+ let existing = '';
54
+ try { existing = fs.readFileSync(hookFile, 'utf8'); } catch { /* new file */ }
55
+
56
+ if (existing.includes(MARKER)) {
57
+ console.log('omnitype: git hook already installed.');
58
+ return;
59
+ }
60
+
61
+ const content = existing
62
+ ? `${existing.trimEnd()}\n\n${snippet}`
63
+ : `#!/bin/sh\n${snippet}`;
64
+
65
+ fs.writeFileSync(hookFile, content);
66
+ if (os.platform() !== 'win32') fs.chmodSync(hookFile, 0o755);
67
+ console.log('omnitype: post-commit hook installed.');
68
+ }
69
+
70
+ export function uninstallHooks(cwd = process.cwd()): void {
71
+ const gitDir = findGitDir(cwd);
72
+ if (!gitDir) throw new Error('Not inside a git repository.');
73
+
74
+ const hookFile = path.join(gitDir, 'hooks', 'post-commit');
75
+ let content: string;
76
+ try { content = fs.readFileSync(hookFile, 'utf8'); }
77
+ catch { console.log('omnitype: no hook file found.'); return; }
78
+
79
+ if (!content.includes(MARKER)) {
80
+ console.log('omnitype: omnitype hook not found in post-commit.');
81
+ return;
82
+ }
83
+
84
+ const start = content.indexOf(MARKER);
85
+ const end = content.indexOf(MARKER_END);
86
+ let updated = content.slice(0, start) + content.slice(end + MARKER_END.length);
87
+ updated = updated.replace(/\n{3,}/g, '\n\n').trim();
88
+
89
+ if (!updated || updated === '#!/bin/sh') {
90
+ fs.rmSync(hookFile);
91
+ } else {
92
+ fs.writeFileSync(hookFile, updated + '\n');
93
+ }
94
+ console.log('omnitype: post-commit hook removed.');
95
+ }
96
+
97
+ /**
98
+ * Called by the post-commit hook. Diffs HEAD against HEAD~1, attributing
99
+ * added lines as AI if a tool is currently active, otherwise as 'user'.
100
+ */
101
+ export async function commitScan(repoPath: string): Promise<void> {
102
+ const api = new ApiClient();
103
+
104
+ // Get the commit hash and message
105
+ const commitHash = run('git', ['-C', repoPath, 'rev-parse', 'HEAD']).trim();
106
+ const commitMsg = run('git', ['-C', repoPath, 'log', '-1', '--format=%s']).trim();
107
+ const branch = run('git', ['-C', repoPath, 'rev-parse', '--abbrev-ref', 'HEAD']).trim();
108
+ const projectName = path.basename(repoPath);
109
+
110
+ // Detect the active model
111
+ const detector = new ModelDetector();
112
+ const detection = detector.detect();
113
+
114
+ // Get diff of committed files (HEAD vs HEAD~1, or HEAD vs empty if first commit)
115
+ let diffOutput: string;
116
+ try {
117
+ diffOutput = run('git', ['-C', repoPath, 'diff', '--unified=0', 'HEAD~1', 'HEAD']);
118
+ } catch {
119
+ // First commit — diff against empty tree
120
+ diffOutput = run('git', ['-C', repoPath, 'diff', '--unified=0', '4b825dc642cb6eb9a060e54bf8d69288fbee4904', 'HEAD']);
121
+ }
122
+
123
+ const files = parseDiffToProvenance(diffOutput, detection.tool !== 'unknown' ? 'ai' : 'user', detection.model);
124
+
125
+ if (Object.keys(files).length === 0) {
126
+ process.stdout.write('omnitype: nothing to record for this commit.\n');
127
+ return;
128
+ }
129
+
130
+ // Write Git Note first (local, always works even offline)
131
+ try {
132
+ const noteFile = provenanceToNoteFile(files);
133
+ const note = buildNote(noteFile);
134
+ await writeNote(repoPath, commitHash, note);
135
+ process.stdout.write(`omnitype: git note written for ${commitHash.slice(0, 7)}\n`);
136
+ } catch (err) {
137
+ process.stderr.write(`omnitype: git note failed — ${err}\n`);
138
+ }
139
+
140
+ // Push to cloud (best-effort)
141
+ if (api.isSignedIn) {
142
+ try {
143
+ await api.pushProvenance(projectName, branch, files, { commitHash, commitMessage: commitMsg, source: 'cli-hook' });
144
+ process.stdout.write(`omnitype: pushed ${Object.keys(files).length} file(s) to cloud\n`);
145
+ } catch (err) {
146
+ process.stderr.write(`omnitype: cloud push failed — ${err}\n`);
147
+ }
148
+ }
149
+ }
150
+
151
+ function run(cmd: string, args: string[]): string {
152
+ return execFileSync(cmd, args, { encoding: 'utf8' });
153
+ }
154
+
155
+ type Origin = 'ai' | 'user' | 'paste' | 'existing';
156
+
157
+ function hashLine(s: string): number {
158
+ let h = 0x811c9dc5;
159
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h * 0x01000193) >>> 0; }
160
+ return h;
161
+ }
162
+
163
+ function parseDiffToProvenance(
164
+ diff: string,
165
+ origin: Origin,
166
+ model: string,
167
+ ): Record<string, StoredProvenance> {
168
+ // Collect added lines per file, then build provenance from scratch
169
+ const fileLines: Record<string, string[]> = {};
170
+ let currentFile: string | null = null;
171
+
172
+ for (const line of diff.split('\n')) {
173
+ if (line.startsWith('+++ b/')) {
174
+ currentFile = line.slice(6).trim();
175
+ if (!fileLines[currentFile]) fileLines[currentFile] = [];
176
+ continue;
177
+ }
178
+ if (!currentFile) continue;
179
+ if (line.startsWith('+') && !line.startsWith('+++')) {
180
+ fileLines[currentFile].push(line.slice(1));
181
+ }
182
+ }
183
+
184
+ const result: Record<string, StoredProvenance> = {};
185
+ for (const [relPath, lines] of Object.entries(fileLines)) {
186
+ if (lines.length === 0) continue;
187
+ const prov = new FileProvenance(0);
188
+ const modelId = origin === 'ai' ? prov.internModel(model) : undefined;
189
+ const baseline: LineHashBaseline[] = lines.map(l => ({ hash: hashLine(l), charLen: l.length + 1 }));
190
+ // Build the baseline first, then apply a diff from empty → lines
191
+ prov.applyLineDiff([], baseline, origin, modelId);
192
+ result[relPath] = prov.toStored(baseline);
193
+ }
194
+ return result;
195
+ }