codeply-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/bin/codeply.js ADDED
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Codeply CLI — apply an AI instruction to a file from the terminal.
4
+ *
5
+ * Single-shot by design (v1): one instruction, one file, one verified diff,
6
+ * one write. No multi-file auto-detection, no conversation memory, no local
7
+ * response cache — those are desktop-app features that can come later if
8
+ * this is worth building out further.
9
+ */
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const readline = require('readline/promises');
13
+ const { Command } = require('commander');
14
+
15
+ const auth = require('../lib/auth');
16
+ const ai = require('../lib/ai');
17
+ const editEngine = require('../lib/edit-engine');
18
+ const applyLimit = require('../lib/apply-limit');
19
+ const config = require('../lib/config');
20
+
21
+ // Google brand palette
22
+ const c = {
23
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
24
+ red: (s) => `\x1b[38;2;234;67;53m${s}\x1b[0m`, // #EA4335
25
+ green: (s) => `\x1b[38;2;52;168;83m${s}\x1b[0m`, // #34A853
26
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
27
+ blue: (s) => `\x1b[38;2;66;133;244m${s}\x1b[0m`, // #4285F4
28
+ yellow: (s) => `\x1b[38;2;251;188;5m${s}\x1b[0m`, // #FBBC05
29
+ accent: (s) => `\x1b[38;2;66;133;244m${s}\x1b[0m`, // primary = blue
30
+ secondary: (s) => `\x1b[38;2;154;160;166m${s}\x1b[0m`,
31
+ muted: (s) => `\x1b[38;2;95;99;104m${s}\x1b[0m`,
32
+ text: (s) => `\x1b[38;2;232;234;237m${s}\x1b[0m`,
33
+ };
34
+
35
+ async function confirm(question) {
36
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
37
+ try {
38
+ const answer = (await rl.question(`${question} (y/N) `)).trim().toLowerCase();
39
+ return answer === 'y' || answer === 'yes';
40
+ } finally {
41
+ rl.close();
42
+ }
43
+ }
44
+
45
+ function printEditPreview(edits) {
46
+ edits.forEach((e, i) => {
47
+ console.log(c.muted(`\n── edit ${i + 1}/${edits.length} ──`));
48
+ e.search.split('\n').forEach(l => console.log(c.red(`- ${l}`)));
49
+ e.replace.split('\n').forEach(l => console.log(c.green(`+ ${l}`)));
50
+ });
51
+ console.log('');
52
+ }
53
+
54
+ async function runApply(instruction, opts) {
55
+ const session = await auth.getSession();
56
+ if (!session) {
57
+ console.error('Not signed in. Run `codeply login` first.');
58
+ process.exitCode = 1;
59
+ return;
60
+ }
61
+
62
+ const filePath = path.resolve(process.cwd(), opts.file);
63
+ if (!fs.existsSync(filePath)) {
64
+ console.error(`File not found: ${filePath}`);
65
+ process.exitCode = 1;
66
+ return;
67
+ }
68
+
69
+ // The shared 100/day cap only applies when spending the Codeply proxy —
70
+ // Ollama (local or the user's own cloud key) doesn't touch that account.
71
+ const usingCodeply = config.getConfig().provider === 'codeply';
72
+ if (usingCodeply) {
73
+ const limit = await applyLimit.checkApplyLimit();
74
+ if (!limit.allowed) {
75
+ console.error(limit.error || `Daily apply limit reached (${limit.count}/${limit.limit}). This is shared with the Codeply desktop app and resets at midnight UTC.`);
76
+ process.exitCode = 1;
77
+ return;
78
+ }
79
+ }
80
+
81
+ console.log(c.secondary(`Asking Codeply about ${path.basename(filePath)}…`));
82
+ const result = await editEngine.computeInstructionEdits(instruction, filePath);
83
+
84
+ if (!result.success) {
85
+ console.error(c.red(`Failed: ${result.error}`));
86
+ process.exitCode = 1;
87
+ return;
88
+ }
89
+ if (!result.edits.length) {
90
+ console.log(result.reason || 'No edits produced — the instruction may be too ambiguous or too large for a targeted change.');
91
+ return;
92
+ }
93
+
94
+ printEditPreview(result.edits);
95
+ if (result.badCount) {
96
+ console.log(c.muted(`Note: ${result.badCount} proposed change(s) couldn't be matched against the file and were skipped.`));
97
+ }
98
+ console.log(c.muted(`${result.reason || ''}${result.reason ? ' — ' : ''}confidence ${result.confidence ?? '?'}%, ${result.tokensUsed || 0} tokens, model ${result.modelUsed || 'unknown'}`));
99
+
100
+ if (opts.dryRun) {
101
+ console.log(c.muted('\n(dry run — nothing written)'));
102
+ return;
103
+ }
104
+
105
+ if (!opts.yes) {
106
+ const ok = await confirm(c.bold(c.accent(`Apply ${result.edits.length} edit(s) to ${path.basename(filePath)}?`)));
107
+ if (!ok) { console.log(c.secondary('Cancelled.')); return; }
108
+ }
109
+
110
+ const fileContent = fs.readFileSync(filePath, 'utf8');
111
+ const applied = editEngine.applyEditsToContent(fileContent, result.edits);
112
+ if (!applied.ok) {
113
+ console.error(`Could not apply edit ${applied.index + 1}: ${applied.error}`);
114
+ process.exitCode = 1;
115
+ return;
116
+ }
117
+
118
+ fs.writeFileSync(filePath, applied.content);
119
+ if (usingCodeply) await applyLimit.recordApplyEvent(filePath);
120
+ console.log(c.green(`✓ Applied to ${filePath}`));
121
+ }
122
+
123
+ // TUI Mode - interactive when no command is provided
124
+ async function runTUI() {
125
+ // Check if running in interactive terminal
126
+ if (!process.stdin.isTTY) {
127
+ const VERSION = require('../package.json').version;
128
+ // C=blue O=red D=yellow E=green P=blue L=red Y=yellow
129
+ // Fixed-width letters + trailing gap so CODEPLY reads cleanly
130
+ const letterColors = [c.blue, c.red, c.yellow, c.green, c.blue, c.red, c.yellow];
131
+ const logoLines = [
132
+ [' ██████╗ ', ' ██████╗ ', '██████╗ ', '███████╗ ', '██████╗ ', '██╗ ', '██╗ ██╗'],
133
+ ['██╔════╝ ', '██╔═══██╗', '██╔══██╗ ', '██╔════╝ ', '██╔══██╗ ', '██║ ', '╚██╗ ██╔╝'],
134
+ ['██║ ', '██║ ██║', '██║ ██║ ', '█████╗ ', '██████╔╝ ', '██║ ', ' ╚████╔╝ '],
135
+ ['██║ ', '██║ ██║', '██║ ██║ ', '██╔══╝ ', '██╔═══╝ ', '██║ ', ' ╚██╔╝ '],
136
+ ['╚██████╗ ', '╚██████╔╝', '██████╔╝ ', '███████╗ ', '██║ ', '███████╗ ', ' ██║ '],
137
+ [' ╚═════╝ ', ' ╚═════╝ ', '╚═════╝ ', '╚══════╝ ', '╚═╝ ', '╚══════╝ ', ' ╚═╝ '],
138
+ ];
139
+
140
+ console.log('');
141
+ logoLines.forEach((line) => {
142
+ console.log(line.map((seg, i) => letterColors[i](seg)).join(''));
143
+ });
144
+ console.log(`
145
+ ${c.secondary('Apply AI instructions. Edit files. Ship faster.')} ${c.muted('v' + VERSION)}
146
+
147
+ ${c.bold(c.text('Usage'))}
148
+ ${c.blue('codeply')} Launch interactive TUI
149
+ ${c.blue('codeply apply')} ${c.dim('<instruction>')} ${c.blue('-f')} ${c.dim('<file>')}
150
+ ${c.red('codeply login')} Sign in with your Codeply account
151
+ ${c.yellow('codeply logout')} Sign out
152
+ ${c.green('codeply whoami')} Show current account
153
+
154
+ ${c.muted('Run in a terminal for the full interactive UI · codeply --help')}
155
+ `);
156
+ return;
157
+ }
158
+
159
+ const React = await import('react');
160
+ const { render } = await import('ink');
161
+ const { default: App } = await import('../lib/tui.mjs');
162
+
163
+ // Take the screen so the app doesn't look like output that happened to land
164
+ // in a shell. Deliberately a plain clear rather than the alternate screen
165
+ // buffer (\x1b[?1049h): alt-screen would discard scrollback on exit, and the
166
+ // whole point of the <Static> transcript is that you can scroll back through
167
+ // it afterwards.
168
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
169
+
170
+ render(React.default.createElement(App), {
171
+ exitOnCtrlC: false, // handled inside the app (clear draft first, then quit)
172
+ });
173
+ }
174
+
175
+ const program = new Command();
176
+ program
177
+ .name('codeply')
178
+ .description('Apply an AI instruction to a file from the terminal.')
179
+ .version(require('../package.json').version);
180
+
181
+ program
182
+ .command('login')
183
+ .description('Sign in with your Codeply account (email + 6-digit code).')
184
+ .action(async () => { await auth.login(); });
185
+
186
+ program
187
+ .command('logout')
188
+ .description('Sign out of the current Codeply session.')
189
+ .action(async () => { await auth.logout(); });
190
+
191
+ program
192
+ .command('whoami')
193
+ .description('Show the currently signed-in account.')
194
+ .action(async () => {
195
+ const session = await auth.getSession();
196
+ console.log(session ? session.user.email : 'Not signed in.');
197
+ });
198
+
199
+ program
200
+ .command('provider [name]')
201
+ .description('Show or set the model backend (codeply | ollama).')
202
+ .option('--key <key>', 'Ollama API key (cloud only; omit for a local daemon)')
203
+ .option('--model <model>', 'Ollama model, e.g. gemma4:31b')
204
+ .option('--host <url>', 'Ollama host (default https://ollama.com; local: http://localhost:11434)')
205
+ .action(async (name, opts) => {
206
+ const config = require('../lib/config');
207
+
208
+ if (!name && !opts.key && !opts.model && !opts.host) {
209
+ const cfg = config.getConfig();
210
+ console.log(`Provider ${c.accent(config.describeProvider(cfg))}`);
211
+ if (cfg.provider === 'ollama') {
212
+ console.log(`Host ${cfg.ollama.host}`);
213
+ console.log(`Model ${cfg.ollama.model}`);
214
+ console.log(`Key ${config.maskKey(cfg.ollama.apiKey)}`);
215
+ }
216
+ console.log(c.muted(`\nConfig ${config.configPath}`));
217
+ console.log(c.muted('Set with codeply provider ollama --key <key> --model gemma4:31b'));
218
+ return;
219
+ }
220
+
221
+ if (name && name !== 'codeply' && name !== 'ollama') {
222
+ console.error(`Unknown provider "${name}". Use "codeply" or "ollama".`);
223
+ process.exitCode = 1;
224
+ return;
225
+ }
226
+
227
+ const patch = {};
228
+ if (name) patch.provider = name;
229
+ const ollama = {};
230
+ if (opts.key) ollama.apiKey = opts.key;
231
+ if (opts.model) ollama.model = opts.model;
232
+ if (opts.host) ollama.host = opts.host;
233
+ if (Object.keys(ollama).length) patch.ollama = ollama;
234
+
235
+ const saved = config.saveConfig(patch);
236
+ if (!saved.ok) {
237
+ console.error(`Could not save config: ${saved.error}`);
238
+ process.exitCode = 1;
239
+ return;
240
+ }
241
+ console.log(c.green(`✓ Provider: ${config.describeProvider(config.getConfig())}`));
242
+ console.log(c.muted(` saved to ${saved.path}`));
243
+ if (opts.key) console.log(c.muted(' the key is stored outside the repo and is never committed'));
244
+ });
245
+
246
+ program
247
+ .command('apply <instruction>')
248
+ .description('Apply an instruction to a file.')
249
+ .requiredOption('-f, --file <path>', 'file to edit')
250
+ .option('-y, --yes', 'apply without confirmation prompt')
251
+ .option('--dry-run', 'show the proposed edits without writing them')
252
+ .action(runApply);
253
+
254
+ // If no arguments provided, launch TUI
255
+ if (process.argv.length <= 2) {
256
+ runTUI();
257
+ } else {
258
+ program.parseAsync(process.argv);
259
+ }