@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/blame.ts ADDED
@@ -0,0 +1,240 @@
1
+ import { execFileSync } from 'child_process';
2
+ import * as path from 'path';
3
+ import chalk from 'chalk';
4
+ import gradient from 'gradient-string';
5
+ import { COLORS, UI } from './core/UI';
6
+ import { resolveFileLinesAsync, getCurrentBranch, findRepoRoot as resolverFindRoot } from './core/ProvenanceResolver';
7
+ import type { Origin, LineInfo } from './core/ProvenanceResolver';
8
+
9
+ const ORIGIN_COLOR: Record<Origin, string> = {
10
+ ai: COLORS.ai,
11
+ user: COLORS.user,
12
+ paste: COLORS.paste,
13
+ existing: '#444444',
14
+ };
15
+
16
+ const ORIGIN_BG: Record<Origin, string> = {
17
+ ai: '#3d0070',
18
+ user: '#003d6b',
19
+ paste: '#4a3000',
20
+ existing: '#1a1a1a',
21
+ };
22
+
23
+ const ORIGIN_BADGE: Record<Origin, string> = {
24
+ ai: ' AI ',
25
+ user: 'USR ',
26
+ paste: 'PST ',
27
+ existing: 'SRC ',
28
+ };
29
+
30
+ function badge(origin: Origin, useColor: boolean): string {
31
+ if (!useColor) return `[${ORIGIN_BADGE[origin].trim()}]`;
32
+ return chalk.bgHex(ORIGIN_BG[origin]).hex(ORIGIN_COLOR[origin]).bold(ORIGIN_BADGE[origin]);
33
+ }
34
+
35
+ function shortModel(model: string): string {
36
+ return model
37
+ .replace(/claude-(\w+)-(\d+)-(\d+).*/, 'c-$1')
38
+ .replace(/gpt-(\w+)/, 'gpt-$1')
39
+ .replace(/gemini-(\w+)/, 'gem-$1')
40
+ .substring(0, 12);
41
+ }
42
+
43
+ function shortTool(tool: string): string {
44
+ return tool
45
+ .replace('claude-code', 'claude')
46
+ .replace('antigravity', 'ag')
47
+ .substring(0, 10);
48
+ }
49
+
50
+ function attrTag(info: LineInfo | undefined, useColor: boolean): string {
51
+ if (!info || info.origin === 'existing') return useColor ? chalk.hex('#333333')('──────────────') : ' ';
52
+ const parts: string[] = [];
53
+ if (info.model) parts.push(shortModel(info.model));
54
+ if (info.tool && info.tool !== 'unknown') parts.push(shortTool(info.tool));
55
+ const tag = parts.join(chalk.hex('#555555')('·'));
56
+ return useColor ? chalk.hex(ORIGIN_COLOR[info.origin])(tag.padEnd(14)) : tag.padEnd(14);
57
+ }
58
+
59
+ function barLine(label: string, count: number, total: number, color: string, width = 24): string {
60
+ const pct = total === 0 ? 0 : count / total;
61
+ const filled = Math.round(pct * width);
62
+ const bar = chalk.hex(color)('█'.repeat(filled)) + chalk.hex('#222222')('█'.repeat(width - filled));
63
+ const pctStr = chalk.hex(color).bold(`${Math.round(pct * 100)}%`.padStart(4));
64
+ const countStr = chalk.hex('#555555')(`${count} lines`.padStart(10));
65
+ return ` ${chalk.hex(color).bold(label.padEnd(8))} ${bar} ${pctStr} ${countStr}`;
66
+ }
67
+
68
+ export interface BlameOptions {
69
+ file: string;
70
+ repoPath?: string;
71
+ noColor?: boolean;
72
+ showStats?: boolean;
73
+ }
74
+
75
+ export async function runBlame(opts: BlameOptions): Promise<void> {
76
+ const filePath = path.resolve(opts.file);
77
+ const repoPath = opts.repoPath ?? findRepoRoot(filePath) ?? process.cwd();
78
+ const relPath = path.relative(repoPath, filePath).replace(/\\/g, '/');
79
+ const useColor = !opts.noColor && process.stdout.isTTY;
80
+ const branch = getCurrentBranch(repoPath);
81
+ const projectName = detectProjectName(repoPath);
82
+
83
+ // ── Header ─────────────────────────────────────────────────────────────────
84
+ if (useColor) {
85
+ const g = (s: string) => gradient([COLORS.primary, COLORS.secondary, COLORS.ai])(s);
86
+ process.stdout.write('\n');
87
+ process.stdout.write(chalk.bold(g(' ◈ OmniType')) + chalk.hex('#444444')(' │ ') + chalk.hex('#888888')('code provenance') + '\n');
88
+ process.stdout.write(chalk.hex('#333333')(' ─────────────────────────────────────────────────────────────') + '\n');
89
+ process.stdout.write(chalk.hex('#555555')(' ◎ ') + chalk.hex('#cccccc').bold(path.basename(filePath)) + chalk.hex('#444444')(` · ${relPath}`) + '\n');
90
+ process.stdout.write(chalk.hex('#555555')(' ⎇ ') + chalk.hex('#58a6ff')(branch) + chalk.hex('#444444')(` · ${projectName}`) + '\n');
91
+ process.stdout.write(chalk.hex('#333333')(' ─────────────────────────────────────────────────────────────') + '\n\n');
92
+ }
93
+
94
+ let blameOut: string;
95
+ try {
96
+ blameOut = execFileSync(
97
+ 'git', ['-C', repoPath, 'blame', '--line-porcelain', filePath],
98
+ { encoding: 'utf8' }
99
+ );
100
+ } catch (err) {
101
+ UI.error(`Git blame failed: ${err}`);
102
+ process.exit(1);
103
+ }
104
+
105
+ const lineMap = await resolveFileLinesAsync(repoPath, relPath, filePath, branch, projectName);
106
+ const blameLines = parsePorcelainBlame(blameOut);
107
+
108
+ const stats: Record<Origin | 'total', number> = { ai: 0, user: 0, paste: 0, existing: 0, total: 0 };
109
+ const modelCounts: Record<string, number> = {};
110
+ const toolCounts: Record<string, number> = {};
111
+
112
+ for (const bl of blameLines) {
113
+ const info = lineMap.get(bl.lineNum);
114
+ const origin = info?.origin ?? 'existing';
115
+
116
+ const sha = useColor ? chalk.hex('#333333')(bl.shortSha) : bl.shortSha;
117
+ const author = useColor ? chalk.hex('#555555')(bl.author.slice(0, 9).padEnd(9)) : bl.author.slice(0, 9).padEnd(9);
118
+ const lineNo = useColor ? chalk.hex('#444444')(bl.lineNum.toString().padStart(4)) : bl.lineNum.toString().padStart(4);
119
+ const bdg = badge(origin, useColor);
120
+ const tag = attrTag(info, useColor);
121
+ const sep = useColor ? chalk.hex(ORIGIN_COLOR[origin])('▏') : '|';
122
+ const code = origin === 'existing'
123
+ ? (useColor ? chalk.hex('#555555')(bl.content) : bl.content)
124
+ : bl.content;
125
+
126
+ process.stdout.write(`${sha} ${author} ${lineNo} ${bdg} ${tag} ${sep} ${code}\n`);
127
+
128
+ stats[origin]++;
129
+ stats.total++;
130
+ if (origin === 'ai') {
131
+ if (info?.model) modelCounts[info.model] = (modelCounts[info.model] ?? 0) + 1;
132
+ if (info?.tool) toolCounts[info.tool] = (toolCounts[info.tool] ?? 0) + 1;
133
+ }
134
+ }
135
+
136
+ if (!opts.showStats || blameLines.length === 0) return;
137
+
138
+ // ── Stats footer ───────────────────────────────────────────────────────────
139
+ const T = stats.total;
140
+ const lines: string[] = [''];
141
+
142
+ // Branding
143
+ if (useColor) {
144
+ const g = (s: string) => gradient([COLORS.primary, COLORS.secondary, COLORS.ai])(s);
145
+ lines.push(' ' + chalk.bold(g('◈ OmniType')) + chalk.hex('#333333')(' · Attribution Report'));
146
+ lines.push(' ' + chalk.hex('#333333')('─'.repeat(52)));
147
+ } else {
148
+ lines.push(' OmniType · Attribution Report');
149
+ lines.push(' ' + '─'.repeat(52));
150
+ }
151
+
152
+ lines.push('');
153
+ lines.push(barLine('AI', stats.ai, T, COLORS.ai));
154
+ lines.push(barLine('Typed', stats.user, T, COLORS.user));
155
+ lines.push(barLine('Pasted', stats.paste, T, COLORS.paste));
156
+ lines.push(barLine('Source', stats.existing, T, '#444444'));
157
+ lines.push('');
158
+
159
+ if (Object.keys(modelCounts).length > 0) {
160
+ lines.push(' ' + chalk.hex('#555555').bold('MODELS'));
161
+ for (const [m, count] of Object.entries(modelCounts).sort(([, a], [, b]) => b - a)) {
162
+ const pct = Math.round(count / T * 100);
163
+ lines.push(` ${chalk.hex(COLORS.ai)(m.padEnd(28))} ${chalk.hex(COLORS.ai).bold(`${pct}%`.padStart(4))} ${chalk.hex('#444444')(`${count} lines`)}`);
164
+ }
165
+ lines.push('');
166
+ }
167
+
168
+ if (Object.keys(toolCounts).length > 0) {
169
+ lines.push(' ' + chalk.hex('#555555').bold('TOOLS'));
170
+ for (const [t, count] of Object.entries(toolCounts).sort(([, a], [, b]) => b - a)) {
171
+ const pct = Math.round(count / T * 100);
172
+ lines.push(` ${chalk.hex(COLORS.primary)(t.padEnd(28))} ${chalk.hex(COLORS.primary).bold(`${pct}%`.padStart(4))} ${chalk.hex('#444444')(`${count} lines`)}`);
173
+ }
174
+ lines.push('');
175
+ }
176
+
177
+ process.stdout.write(lines.join('\n') + '\n');
178
+ }
179
+
180
+ // ── Porcelain blame parser ────────────────────────────────────────────────────
181
+
182
+ interface BlameLine {
183
+ lineNum: number;
184
+ shortSha: string;
185
+ author: string;
186
+ date: string;
187
+ content: string;
188
+ }
189
+
190
+ function parsePorcelainBlame(raw: string): BlameLine[] {
191
+ const lines = raw.split('\n');
192
+ const result: BlameLine[] = [];
193
+ const cache = new Map<string, { author: string; date: string }>();
194
+
195
+ let i = 0;
196
+ while (i < lines.length) {
197
+ const header = lines[i];
198
+ if (!header || header.length < 40) { i++; continue; }
199
+
200
+ const sha = header.slice(0, 40);
201
+ const parts = header.split(' ');
202
+ const lineNum = parseInt(parts[2] ?? parts[1], 10);
203
+ if (isNaN(lineNum)) { i++; continue; }
204
+
205
+ let author = cache.get(sha)?.author ?? '';
206
+ let date = cache.get(sha)?.date ?? '';
207
+
208
+ i++;
209
+ while (i < lines.length && !lines[i].startsWith('\t')) {
210
+ const l = lines[i];
211
+ if (l.startsWith('author ') && !author) author = l.slice(7).trim();
212
+ if (l.startsWith('author-time ') && !date) {
213
+ date = new Date(parseInt(l.slice(12), 10) * 1000).toISOString().slice(0, 10);
214
+ }
215
+ i++;
216
+ }
217
+
218
+ if (!cache.has(sha)) cache.set(sha, { author, date });
219
+ const content = (lines[i] ?? '').slice(1);
220
+ i++;
221
+
222
+ result.push({ lineNum, shortSha: sha.slice(0, 7), author, date, content });
223
+ }
224
+
225
+ return result;
226
+ }
227
+
228
+ function findRepoRoot(startPath: string): string | undefined {
229
+ return resolverFindRoot(startPath);
230
+ }
231
+
232
+ function detectProjectName(repoPath: string): string {
233
+ try {
234
+ const remotes = execFileSync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'],
235
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
236
+ const match = remotes.replace(/\.git$/, '').match(/[/:]([\w.-]+)$/);
237
+ if (match) return match[1];
238
+ } catch { /* no remote */ }
239
+ return path.basename(repoPath);
240
+ }
@@ -0,0 +1,197 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import * as zlib from 'zlib';
5
+ import { promisify } from 'util';
6
+ import type { StoredProvenance } from './FileProvenance';
7
+
8
+ const _gzip = promisify(zlib.gzip);
9
+
10
+ const CONFIG_PATH = path.join(os.homedir(), '.omnitype', 'config.json');
11
+ const DEFAULT_API = 'https://api.imrishav.life';
12
+
13
+ interface Config {
14
+ token?: string;
15
+ username?: string;
16
+ apiUrl?: string;
17
+ }
18
+
19
+ class Semaphore {
20
+ private queue: Array<() => void> = [];
21
+ private count: number;
22
+ constructor(max: number) { this.count = max; }
23
+ acquire(): Promise<void> {
24
+ if (this.count > 0) { this.count--; return Promise.resolve(); }
25
+ return new Promise(res => this.queue.push(res));
26
+ }
27
+ release(): void {
28
+ const next = this.queue.shift();
29
+ if (next) next(); else this.count++;
30
+ }
31
+ }
32
+
33
+ export class ApiClient {
34
+ private config: Config = {};
35
+
36
+ constructor() {
37
+ this._loadConfig();
38
+ }
39
+
40
+ private _loadConfig(): void {
41
+ try {
42
+ this.config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
43
+ } catch { this.config = {}; }
44
+ }
45
+
46
+ private _saveConfig(): void {
47
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
48
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(this.config, null, 2));
49
+ }
50
+
51
+ get apiUrl(): string {
52
+ return (this.config.apiUrl ?? DEFAULT_API).replace(/\/+$/, '');
53
+ }
54
+
55
+ get token(): string | undefined { return this.config.token; }
56
+ get username(): string | undefined { return this.config.username; }
57
+ get isSignedIn(): boolean { return !!this.config.token; }
58
+
59
+ async login(email: string, password: string): Promise<string> {
60
+ const res = await fetch(`${this.apiUrl}/auth/login`, {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify({
64
+ identifier: email, password,
65
+ device_type: 'cli',
66
+ device_name: `omnitype-cli on ${os.hostname()}`,
67
+ }),
68
+ });
69
+ if (!res.ok) throw new Error(`Login failed: ${await res.text()}`);
70
+ const data = await res.json() as { token: string; user: { username: string } };
71
+ this.config.token = data.token;
72
+ this.config.username = data.user.username;
73
+ this._saveConfig();
74
+ return data.user.username;
75
+ }
76
+
77
+ async pullProvenance(projectName: string, paths?: string[]): Promise<Record<string, any> | null> {
78
+ if (!this.config.token) return null;
79
+ try {
80
+ const body: any = { project_name: projectName };
81
+ if (paths?.length) body.paths = paths;
82
+ const res = await fetch(`${this.apiUrl}/provenance/pull`, {
83
+ method: 'POST',
84
+ headers: {
85
+ 'Authorization': `Bearer ${this.config.token}`,
86
+ 'Content-Type': 'application/json',
87
+ },
88
+ body: JSON.stringify(body),
89
+ });
90
+ if (!res.ok) return null;
91
+ const data = await res.json() as { files?: Record<string, any> };
92
+ return data.files ?? null;
93
+ } catch { return null; }
94
+ }
95
+
96
+ async getPersonalStats(): Promise<any> {
97
+ if (!this.config.token) throw new Error('Not signed in. Run: omnitype login');
98
+ const res = await fetch(`${this.apiUrl}/provenance/personal/projects`, {
99
+ headers: { 'Authorization': `Bearer ${this.config.token}` },
100
+ });
101
+ if (!res.ok) throw new Error(`Failed to fetch stats: HTTP ${res.status}`);
102
+ return res.json();
103
+ }
104
+
105
+ async getProfile(): Promise<any> {
106
+ if (!this.config.token) throw new Error('Not signed in. Run: omnitype login');
107
+ const res = await fetch(`${this.apiUrl}/auth/me`, {
108
+ headers: { 'Authorization': `Bearer ${this.config.token}` },
109
+ });
110
+ if (!res.ok) throw new Error(`Failed to fetch profile: HTTP ${res.status}`);
111
+ return res.json();
112
+ }
113
+
114
+ logout(): void {
115
+ delete this.config.token;
116
+ delete this.config.username;
117
+ this._saveConfig();
118
+ }
119
+
120
+ async pushProvenance(
121
+ projectName: string,
122
+ branch: string,
123
+ files: Record<string, StoredProvenance>,
124
+ opts: { commitHash?: string; deletedFiles?: string[]; commitMessage?: string; personalOnly?: boolean; source?: string } = {},
125
+ ): Promise<void> {
126
+ if (!this.config.token) throw new Error('Not signed in. Run: omnitype login');
127
+
128
+ const fileEntries = Object.entries(files);
129
+ if (fileEntries.length === 0 && !opts.deletedFiles?.length) return;
130
+
131
+ const MAX_CHUNK = 400 * 1024;
132
+ const chunks = this._buildChunks(fileEntries, MAX_CHUNK);
133
+ const total = chunks.length || 1;
134
+ const effective = chunks.length > 0 ? chunks : [{}];
135
+ const sem = new Semaphore(4);
136
+ const failed: number[] = [];
137
+
138
+ await Promise.all(effective.map(async (chunk, i) => {
139
+ await sem.acquire();
140
+ try {
141
+ const body: Record<string, unknown> = {
142
+ project_name: projectName, branch, files: chunk,
143
+ source: opts.source ?? 'cli-daemon',
144
+ };
145
+ if (i === 0) {
146
+ if (opts.commitHash) body['commit_hash'] = opts.commitHash;
147
+ if (opts.commitMessage) body['commit_message'] = opts.commitMessage;
148
+ if (opts.deletedFiles?.length) body['deleted_files'] = opts.deletedFiles;
149
+ if (opts.personalOnly) body['personal_only'] = true;
150
+ }
151
+ await this._postWithRetry(`${this.apiUrl}/provenance`, body, 3, `chunk ${i + 1}/${total}`);
152
+ } catch { failed.push(i + 1); }
153
+ finally { sem.release(); }
154
+ }));
155
+
156
+ if (failed.length) throw new Error(`Push failed for chunks: ${failed.join(', ')}`);
157
+ }
158
+
159
+ private async _postWithRetry(url: string, body: unknown, attempts: number, label: string): Promise<void> {
160
+ const compressed = await _gzip(Buffer.from(JSON.stringify(body)));
161
+ for (let i = 0; i < attempts; i++) {
162
+ const res = await fetch(url, {
163
+ method: 'POST',
164
+ headers: {
165
+ 'Authorization': `Bearer ${this.config.token}`,
166
+ 'Content-Type': 'application/json',
167
+ 'Content-Encoding': 'gzip',
168
+ },
169
+ body: compressed,
170
+ });
171
+ if (res.ok) return;
172
+ if (res.status < 500 || i === attempts - 1) throw new Error(`${label}: HTTP ${res.status}`);
173
+ await new Promise(r => setTimeout(r, 500 * 2 ** i));
174
+ }
175
+ }
176
+
177
+ private _buildChunks(
178
+ entries: Array<[string, StoredProvenance]>,
179
+ maxBytes: number,
180
+ ): Array<Record<string, StoredProvenance>> {
181
+ const chunks: Array<Record<string, StoredProvenance>> = [];
182
+ let cur: Record<string, StoredProvenance> = {};
183
+ let curBytes = 0;
184
+ for (const [k, v] of entries) {
185
+ const size = JSON.stringify(v).length;
186
+ if (curBytes + size > maxBytes && curBytes > 0) {
187
+ chunks.push(cur);
188
+ cur = {};
189
+ curBytes = 0;
190
+ }
191
+ cur[k] = v;
192
+ curBytes += size;
193
+ }
194
+ if (Object.keys(cur).length) chunks.push(cur);
195
+ return chunks;
196
+ }
197
+ }