newo 2.0.5 → 3.0.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +146 -0
  3. package/dist/api.d.ts +17 -1
  4. package/dist/api.js +78 -0
  5. package/dist/cli/commands/create-agent.d.ts +3 -0
  6. package/dist/cli/commands/create-agent.js +75 -0
  7. package/dist/cli/commands/create-attribute.d.ts +3 -0
  8. package/dist/cli/commands/create-attribute.js +63 -0
  9. package/dist/cli/commands/create-event.d.ts +3 -0
  10. package/dist/cli/commands/create-event.js +66 -0
  11. package/dist/cli/commands/create-flow.d.ts +3 -0
  12. package/dist/cli/commands/create-flow.js +100 -0
  13. package/dist/cli/commands/create-parameter.d.ts +3 -0
  14. package/dist/cli/commands/create-parameter.js +47 -0
  15. package/dist/cli/commands/create-persona.d.ts +3 -0
  16. package/dist/cli/commands/create-persona.js +43 -0
  17. package/dist/cli/commands/create-project.d.ts +3 -0
  18. package/dist/cli/commands/create-project.js +55 -0
  19. package/dist/cli/commands/create-skill.d.ts +3 -0
  20. package/dist/cli/commands/create-skill.js +115 -0
  21. package/dist/cli/commands/create-state.d.ts +3 -0
  22. package/dist/cli/commands/create-state.js +58 -0
  23. package/dist/cli/commands/delete-agent.d.ts +3 -0
  24. package/dist/cli/commands/delete-agent.js +70 -0
  25. package/dist/cli/commands/delete-flow.d.ts +3 -0
  26. package/dist/cli/commands/delete-flow.js +83 -0
  27. package/dist/cli/commands/delete-skill.d.ts +3 -0
  28. package/dist/cli/commands/delete-skill.js +87 -0
  29. package/dist/cli/commands/help.js +104 -22
  30. package/dist/cli/commands/push.js +4 -3
  31. package/dist/cli.js +48 -0
  32. package/dist/sync/diff-utils.d.ts +18 -0
  33. package/dist/sync/diff-utils.js +152 -0
  34. package/dist/sync/push.d.ts +1 -1
  35. package/dist/sync/push.js +372 -4
  36. package/dist/sync/skill-files.js +22 -49
  37. package/dist/sync/status.js +178 -1
  38. package/dist/types.d.ts +100 -1
  39. package/package.json +1 -1
  40. package/src/api.ts +118 -1
  41. package/src/cli/commands/create-agent.ts +96 -0
  42. package/src/cli/commands/create-attribute.ts +75 -0
  43. package/src/cli/commands/create-event.ts +79 -0
  44. package/src/cli/commands/create-flow.ts +124 -0
  45. package/src/cli/commands/create-parameter.ts +59 -0
  46. package/src/cli/commands/create-persona.ts +54 -0
  47. package/src/cli/commands/create-project.ts +66 -0
  48. package/src/cli/commands/create-skill.ts +144 -0
  49. package/src/cli/commands/create-state.ts +71 -0
  50. package/src/cli/commands/delete-agent.ts +90 -0
  51. package/src/cli/commands/delete-flow.ts +105 -0
  52. package/src/cli/commands/delete-skill.ts +110 -0
  53. package/src/cli/commands/help.ts +104 -22
  54. package/src/cli/commands/push.ts +5 -3
  55. package/src/cli.ts +60 -0
  56. package/src/sync/diff-utils.ts +168 -0
  57. package/src/sync/push.ts +413 -5
  58. package/src/sync/skill-files.ts +22 -57
  59. package/src/sync/status.ts +183 -1
  60. package/src/types.ts +122 -2
package/dist/sync/push.js CHANGED
@@ -1,19 +1,131 @@
1
1
  /**
2
2
  * Push operations for changed files
3
3
  */
4
- import { updateSkill } from '../api.js';
5
- import { ensureState, mapPath, skillMetadataPath } from '../fsutil.js';
6
- import { validateSkillFolder, getSingleSkillFile } from './skill-files.js';
4
+ import { updateSkill, createAgent, createFlow, createSkill, publishFlow } from '../api.js';
5
+ import { ensureState, mapPath, skillMetadataPath, projectDir, agentMetadataPath } from '../fsutil.js';
6
+ import { validateSkillFolder, getSingleSkillFile, getExtensionForRunner } from './skill-files.js';
7
7
  import fs from 'fs-extra';
8
8
  import { sha256, loadHashes, saveHashes } from '../hash.js';
9
9
  import yaml from 'js-yaml';
10
10
  import { generateFlowsYaml } from './metadata.js';
11
11
  import { isProjectMap, isLegacyProjectMap } from './projects.js';
12
12
  import { flowsYamlPath } from '../fsutil.js';
13
+ /**
14
+ * Scan filesystem for local-only entities not in the project map yet
15
+ */
16
+ async function scanForLocalOnlyEntities(customer, projects, verbose = false) {
17
+ const localEntities = [];
18
+ let agentCount = 0;
19
+ let flowCount = 0;
20
+ let skillCount = 0;
21
+ // Scan each project directory
22
+ for (const [projectIdn] of Object.entries(projects)) {
23
+ const projDir = projectDir(customer.idn, projectIdn);
24
+ if (!(await fs.pathExists(projDir)))
25
+ continue;
26
+ if (verbose)
27
+ console.log(`šŸ” Scanning project directory: ${projDir}`);
28
+ // Get all subdirectories in the project (these should be agents)
29
+ const agentDirs = await fs.readdir(projDir);
30
+ for (const agentIdn of agentDirs) {
31
+ const agentPath = `${projDir}/${agentIdn}`;
32
+ const agentStat = await fs.stat(agentPath);
33
+ // Skip files, only process directories
34
+ if (!agentStat.isDirectory())
35
+ continue;
36
+ // Skip if it's not really an agent directory (no metadata.yaml)
37
+ const agentMetaPath = agentMetadataPath(customer.idn, projectIdn, agentIdn);
38
+ if (!(await fs.pathExists(agentMetaPath)))
39
+ continue;
40
+ // Check if this agent is already in the project map
41
+ const projectData = projects[projectIdn];
42
+ if (!projectData?.agents[agentIdn]) {
43
+ // This is a local-only agent!
44
+ localEntities.push({
45
+ type: 'agent',
46
+ path: agentMetaPath,
47
+ idn: agentIdn,
48
+ projectIdn
49
+ });
50
+ agentCount++;
51
+ if (verbose)
52
+ console.log(` šŸ†• Found local-only agent: ${agentIdn}`);
53
+ }
54
+ // Now scan for flows within this agent (regardless of whether agent is local-only or not)
55
+ try {
56
+ const flowDirs = await fs.readdir(agentPath);
57
+ for (const flowIdn of flowDirs) {
58
+ const flowPath = `${agentPath}/${flowIdn}`;
59
+ const flowStat = await fs.stat(flowPath);
60
+ // Skip files, only process directories
61
+ if (!flowStat.isDirectory())
62
+ continue;
63
+ // Skip if it's not really a flow directory (no metadata.yaml)
64
+ const flowMetaPath = `${flowPath}/metadata.yaml`;
65
+ if (!(await fs.pathExists(flowMetaPath)))
66
+ continue;
67
+ // Check if this flow exists in the project map
68
+ const agentData = projectData?.agents[agentIdn];
69
+ if (!agentData?.flows[flowIdn]) {
70
+ // This is a local-only flow!
71
+ localEntities.push({
72
+ type: 'flow',
73
+ path: flowMetaPath,
74
+ idn: flowIdn,
75
+ projectIdn,
76
+ agentIdn
77
+ });
78
+ flowCount++;
79
+ if (verbose)
80
+ console.log(` šŸ†• Found local-only flow: ${agentIdn}/${flowIdn}`);
81
+ }
82
+ // Now scan for skills within this flow (regardless of whether flow is local-only or not)
83
+ try {
84
+ const skillDirs = await fs.readdir(flowPath);
85
+ for (const skillIdn of skillDirs) {
86
+ const skillPath = `${flowPath}/${skillIdn}`;
87
+ const skillStat = await fs.stat(skillPath);
88
+ // Skip files, only process directories
89
+ if (!skillStat.isDirectory())
90
+ continue;
91
+ // Skip if it's not really a skill directory (no metadata.yaml)
92
+ const skillMetaPath = `${skillPath}/metadata.yaml`;
93
+ if (!(await fs.pathExists(skillMetaPath)))
94
+ continue;
95
+ // Check if this skill exists in the project map
96
+ const flowData = agentData?.flows[flowIdn];
97
+ if (!flowData?.skills[skillIdn]) {
98
+ // This is a local-only skill!
99
+ localEntities.push({
100
+ type: 'skill',
101
+ path: skillMetaPath,
102
+ idn: skillIdn,
103
+ projectIdn,
104
+ agentIdn,
105
+ flowIdn
106
+ });
107
+ skillCount++;
108
+ if (verbose)
109
+ console.log(` šŸ†• Found local-only skill: ${agentIdn}/${flowIdn}/${skillIdn}`);
110
+ }
111
+ }
112
+ }
113
+ catch (error) {
114
+ // Ignore errors reading flow directory
115
+ }
116
+ }
117
+ }
118
+ catch (error) {
119
+ // Ignore errors reading agent directory
120
+ }
121
+ }
122
+ }
123
+ return { agentCount, flowCount, skillCount, entities: localEntities };
124
+ }
13
125
  /**
14
126
  * Push changed files to NEWO platform
15
127
  */
16
- export async function pushChanged(client, customer, verbose = false) {
128
+ export async function pushChanged(client, customer, verbose = false, shouldPublish = true) {
17
129
  await ensureState(customer.idn);
18
130
  if (!(await fs.pathExists(mapPath(customer.idn)))) {
19
131
  console.log(`No map for customer ${customer.idn}. Run \`newo pull --customer ${customer.idn}\` first.`);
@@ -33,6 +145,184 @@ export async function pushChanged(client, customer, verbose = false) {
33
145
  : isLegacyProjectMap(idMapData)
34
146
  ? { '': idMapData }
35
147
  : (() => { throw new Error('Invalid project map format'); })();
148
+ // First, handle any local-only entities (created locally but not yet pushed)
149
+ const localScan = await scanForLocalOnlyEntities(customer, projects, verbose);
150
+ const totalLocalEntities = localScan.agentCount + localScan.flowCount + localScan.skillCount;
151
+ if (totalLocalEntities > 0) {
152
+ console.log(`šŸ“¤ Found ${localScan.agentCount} new agent(s), ${localScan.flowCount} new flow(s), ${localScan.skillCount} new skill(s) to create...`);
153
+ // Process in order: agents first, then flows, then skills
154
+ const sortedEntities = localScan.entities.sort((a, b) => {
155
+ const typeOrder = { 'agent': 0, 'flow': 1, 'skill': 2 };
156
+ return typeOrder[a.type] - typeOrder[b.type];
157
+ });
158
+ for (const entity of sortedEntities) {
159
+ if (entity.type === 'agent') {
160
+ try {
161
+ // Read agent metadata
162
+ const metadataContent = await fs.readFile(entity.path, 'utf8');
163
+ const metadata = yaml.load(metadataContent);
164
+ if (verbose)
165
+ console.log(`šŸ“¤ Creating agent: ${entity.idn}`);
166
+ // Get project ID from the project map
167
+ const projectData = projects[entity.projectIdn];
168
+ if (!projectData?.projectId) {
169
+ console.error(`āŒ Project ID not found for project: ${entity.projectIdn}`);
170
+ continue;
171
+ }
172
+ // Create agent on NEWO platform using project-specific v2 endpoint
173
+ const createAgentRequest = {
174
+ idn: metadata.idn,
175
+ title: metadata.title || metadata.idn,
176
+ description: metadata.description || null,
177
+ persona_id: metadata.persona_id || null
178
+ };
179
+ const createResponse = await createAgent(client, projectData.projectId, createAgentRequest);
180
+ console.log(`āœ… Agent created: ${entity.idn} (ID: ${createResponse.id})`);
181
+ pushed++;
182
+ metadataChanged = true;
183
+ // Update the metadata with the new ID
184
+ metadata.id = createResponse.id;
185
+ metadata.updated_at = new Date().toISOString();
186
+ const updatedMetadataYaml = yaml.dump(metadata, { indent: 2, quotingType: '"', forceQuotes: false });
187
+ await fs.writeFile(entity.path, updatedMetadataYaml);
188
+ // Update the project map to include the new agent
189
+ if (!projectData.agents[entity.idn]) {
190
+ projectData.agents[entity.idn] = {
191
+ id: createResponse.id,
192
+ flows: {}
193
+ };
194
+ }
195
+ }
196
+ catch (error) {
197
+ console.error(`āŒ Failed to create agent ${entity.idn}:`, error.response?.data?.message || error.message);
198
+ }
199
+ }
200
+ else if (entity.type === 'flow') {
201
+ try {
202
+ // Read flow metadata
203
+ const metadataContent = await fs.readFile(entity.path, 'utf8');
204
+ const metadata = yaml.load(metadataContent);
205
+ if (verbose)
206
+ console.log(`šŸ“¤ Creating flow: ${entity.agentIdn}/${entity.idn}`);
207
+ // Get agent ID from the project map
208
+ const projectData = projects[entity.projectIdn];
209
+ if (!entity.agentIdn) {
210
+ console.error(`āŒ Agent IDN missing for flow: ${entity.idn}`);
211
+ continue;
212
+ }
213
+ const agentData = projectData?.agents[entity.agentIdn];
214
+ if (!agentData?.id) {
215
+ console.error(`āŒ Agent ID not found for agent: ${entity.agentIdn}`);
216
+ continue;
217
+ }
218
+ // Create flow on NEWO platform
219
+ const createFlowRequest = {
220
+ idn: metadata.idn,
221
+ title: metadata.title || metadata.idn
222
+ };
223
+ const createResponse = await createFlow(client, agentData.id, createFlowRequest);
224
+ console.log(`āœ… Flow created: ${entity.idn} (ID: ${createResponse.id})`);
225
+ pushed++;
226
+ metadataChanged = true;
227
+ // Handle the special case where NEWO flow API returns empty response
228
+ if (createResponse.id === 'pending-sync') {
229
+ console.log(`āœ… Flow created: ${entity.idn} (ID will be synced on next pull)`);
230
+ // Mark flow as created but pending ID sync
231
+ metadata.id = ''; // Keep empty until sync
232
+ metadata.updated_at = new Date().toISOString();
233
+ const updatedMetadataYaml = yaml.dump(metadata, { indent: 2, quotingType: '"', forceQuotes: false });
234
+ await fs.writeFile(entity.path, updatedMetadataYaml);
235
+ // Update the project map with empty ID (will be filled by pull)
236
+ if (!agentData.flows[entity.idn]) {
237
+ agentData.flows[entity.idn] = {
238
+ id: '', // Empty until synced
239
+ skills: {}
240
+ };
241
+ }
242
+ }
243
+ else {
244
+ // Normal case with ID returned
245
+ metadata.id = createResponse.id;
246
+ metadata.updated_at = new Date().toISOString();
247
+ const updatedMetadataYaml = yaml.dump(metadata, { indent: 2, quotingType: '"', forceQuotes: false });
248
+ await fs.writeFile(entity.path, updatedMetadataYaml);
249
+ // Update the project map to include the new flow
250
+ if (!agentData.flows[entity.idn]) {
251
+ agentData.flows[entity.idn] = {
252
+ id: createResponse.id,
253
+ skills: {}
254
+ };
255
+ }
256
+ }
257
+ }
258
+ catch (error) {
259
+ console.error(`āŒ Failed to create flow ${entity.idn}:`, error.response?.data?.message || error.message);
260
+ }
261
+ }
262
+ else if (entity.type === 'skill') {
263
+ try {
264
+ // Read skill metadata
265
+ const metadataContent = await fs.readFile(entity.path, 'utf8');
266
+ const metadata = yaml.load(metadataContent);
267
+ if (verbose)
268
+ console.log(`šŸ“¤ Creating skill: ${entity.agentIdn}/${entity.flowIdn}/${entity.idn}`);
269
+ // Get flow ID from the project map
270
+ const projectData = projects[entity.projectIdn];
271
+ if (!entity.agentIdn || !entity.flowIdn) {
272
+ console.error(`āŒ Agent IDN or Flow IDN missing for skill: ${entity.idn}`);
273
+ continue;
274
+ }
275
+ const agentData = projectData?.agents[entity.agentIdn];
276
+ const flowData = agentData?.flows[entity.flowIdn];
277
+ if (!flowData?.id) {
278
+ console.error(`āŒ Flow ID not found for flow: ${entity.flowIdn}`);
279
+ continue;
280
+ }
281
+ // Read the skill script content
282
+ const skillFolderBase = entity.path.replace('/metadata.yaml', '');
283
+ const scriptExtension = getExtensionForRunner(metadata.runner_type);
284
+ const scriptPath = `${skillFolderBase}/${entity.idn}.${scriptExtension}`;
285
+ let scriptContent = '';
286
+ if (await fs.pathExists(scriptPath)) {
287
+ scriptContent = await fs.readFile(scriptPath, 'utf8');
288
+ }
289
+ // Create skill on NEWO platform
290
+ const createSkillRequest = {
291
+ idn: metadata.idn,
292
+ title: metadata.title || metadata.idn,
293
+ prompt_script: scriptContent,
294
+ runner_type: metadata.runner_type,
295
+ model: metadata.model,
296
+ path: "", // Empty path as shown in curl example
297
+ parameters: metadata.parameters || []
298
+ };
299
+ const createResponse = await createSkill(client, flowData.id, createSkillRequest);
300
+ console.log(`āœ… Skill created: ${entity.idn} (ID: ${createResponse.id})`);
301
+ pushed++;
302
+ metadataChanged = true;
303
+ // Update the metadata with the new ID
304
+ metadata.id = createResponse.id;
305
+ metadata.updated_at = new Date().toISOString();
306
+ const updatedMetadataYaml = yaml.dump(metadata, { indent: 2, quotingType: '"', forceQuotes: false });
307
+ await fs.writeFile(entity.path, updatedMetadataYaml);
308
+ // Update the project map to include the new skill
309
+ if (!flowData.skills[entity.idn]) {
310
+ flowData.skills[entity.idn] = {
311
+ id: createResponse.id,
312
+ idn: metadata.idn,
313
+ title: metadata.title || metadata.idn,
314
+ runner_type: metadata.runner_type,
315
+ model: metadata.model,
316
+ parameters: metadata.parameters || []
317
+ };
318
+ }
319
+ }
320
+ catch (error) {
321
+ console.error(`āŒ Failed to create skill ${entity.idn}:`, error.response?.data?.message || error.message);
322
+ }
323
+ }
324
+ }
325
+ }
36
326
  for (const [projectIdn, projectData] of Object.entries(projects)) {
37
327
  if (verbose && projectIdn)
38
328
  console.log(`šŸ“ Checking project: ${projectIdn}`);
@@ -164,8 +454,86 @@ export async function pushChanged(client, customer, verbose = false) {
164
454
  const flowsYamlFilePath = flowsYamlPath(customer.idn);
165
455
  newHashes[flowsYamlFilePath] = sha256(flowsYamlContent);
166
456
  }
457
+ // Save updated project map if metadata changed (new agents added)
458
+ if (metadataChanged) {
459
+ const updatedMapData = isProjectMap(idMapData)
460
+ ? { projects }
461
+ : projects['']; // Legacy format
462
+ if (verbose)
463
+ console.log(`šŸ’¾ Saving updated project map...`);
464
+ await fs.writeJson(mapPath(customer.idn), updatedMapData, { spaces: 2 });
465
+ }
167
466
  // Save updated hashes
168
467
  await saveHashes(newHashes, customer.idn);
169
468
  console.log(pushed ? `${pushed} file(s) pushed.` : 'No changes to push.');
469
+ // Publish flows if requested (default behavior)
470
+ if (shouldPublish && pushed > 0) {
471
+ if (verbose)
472
+ console.log('\nšŸš€ Publishing flows...');
473
+ let publishedFlows = 0;
474
+ let failedFlows = 0;
475
+ const publishErrors = [];
476
+ for (const [, projectData] of Object.entries(projects)) {
477
+ for (const [, agentObj] of Object.entries(projectData.agents)) {
478
+ for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
479
+ if (flowObj.id) {
480
+ try {
481
+ const publishData = {
482
+ version: "1.0",
483
+ description: "Published via NEWO CLI",
484
+ type: "public"
485
+ };
486
+ await publishFlow(client, flowObj.id, publishData);
487
+ if (verbose)
488
+ console.log(`šŸ“¤ Published flow: ${flowIdn} (${flowObj.id})`);
489
+ publishedFlows++;
490
+ }
491
+ catch (error) {
492
+ failedFlows++;
493
+ // Extract detailed error information from API response
494
+ const errorMessage = error.response?.data?.message || error.message || 'Unknown error';
495
+ const errorDetails = error.response?.data?.reasons || error.response?.data?.errors || error.response?.data?.detail;
496
+ publishErrors.push({
497
+ flowIdn,
498
+ error: errorMessage,
499
+ details: errorDetails
500
+ });
501
+ // Always show publish errors (not just in verbose mode)
502
+ console.error(`āŒ Failed to publish flow '${flowIdn}': ${errorMessage}`);
503
+ if (errorDetails) {
504
+ if (Array.isArray(errorDetails)) {
505
+ console.error(` Reasons:`);
506
+ errorDetails.forEach((reason) => {
507
+ console.error(` • ${reason}`);
508
+ });
509
+ }
510
+ else if (typeof errorDetails === 'object') {
511
+ console.error(` Details: ${JSON.stringify(errorDetails, null, 2)}`);
512
+ }
513
+ else {
514
+ console.error(` Details: ${errorDetails}`);
515
+ }
516
+ }
517
+ }
518
+ }
519
+ }
520
+ }
521
+ }
522
+ // Summary message
523
+ if (publishedFlows > 0 || failedFlows > 0) {
524
+ console.log(`\nšŸš€ Publish summary: ${publishedFlows} succeeded, ${failedFlows} failed.`);
525
+ if (failedFlows > 0) {
526
+ console.log(`\nāš ļø ${failedFlows} flow(s) failed to publish due to validation errors.`);
527
+ console.log(` Fix the errors above and run 'npm run push' again.`);
528
+ }
529
+ }
530
+ else if (verbose) {
531
+ console.log('\nšŸ’” No flows to publish.');
532
+ }
533
+ }
534
+ // If we created flows, recommend a pull to sync flow IDs
535
+ if (localScan.flowCount > 0) {
536
+ console.log('\nšŸ’” Tip: Run "newo pull" to sync flow IDs and enable skill creation.');
537
+ }
170
538
  }
171
539
  //# sourceMappingURL=push.js.map
@@ -107,62 +107,35 @@ export async function askForOverwrite(skillIdn, existingContent, newContent, fil
107
107
  const greenBg = '\x1b[102m\x1b[30m'; // Light green background, black text (like GitHub)
108
108
  const gray = '\x1b[90m';
109
109
  const reset = '\x1b[0m';
110
- // Show a GitHub-style colored diff with line numbers and context
111
- const localLines = existingContent.trim().split('\n');
112
- const remoteLines = newContent.trim().split('\n');
113
- // Find the first differing section and show it with context
114
- let diffStart = -1;
115
- let diffEnd = -1;
116
- // Find first difference
117
- for (let i = 0; i < Math.max(localLines.length, remoteLines.length); i++) {
118
- if (localLines[i] !== remoteLines[i]) {
119
- diffStart = i;
120
- break;
121
- }
122
- }
123
- if (diffStart === -1) {
124
- // No differences found (shouldn't happen, but handle gracefully)
110
+ // Generate proper diff using LCS algorithm
111
+ const { generateDiff, filterDiffWithContext } = await import('./diff-utils.js');
112
+ const fullDiff = generateDiff(existingContent, newContent);
113
+ const contextDiff = filterDiffWithContext(fullDiff, 2);
114
+ if (contextDiff.length === 0) {
125
115
  console.log(`${gray} No differences found${reset}`);
126
116
  return 'no';
127
117
  }
128
- // Find end of difference section
129
- diffEnd = diffStart;
130
- while (diffEnd < Math.max(localLines.length, remoteLines.length) &&
131
- (localLines[diffEnd] !== remoteLines[diffEnd] ||
132
- localLines[diffEnd] === undefined ||
133
- remoteLines[diffEnd] === undefined)) {
134
- diffEnd++;
135
- }
136
- // Show context before (2 lines)
137
- const contextStart = Math.max(0, diffStart - 2);
138
- for (let i = contextStart; i < diffStart; i++) {
139
- if (localLines[i] !== undefined) {
140
- console.log(` ${String(i + 1).padStart(3)} ${localLines[i]}`);
118
+ // Display the diff with proper GitHub-style formatting
119
+ for (const line of contextDiff) {
120
+ if (line.type === 'context') {
121
+ // Show context lines in gray
122
+ const lineNum = line.localLineNum !== -1 ? line.localLineNum : line.remoteLineNum;
123
+ console.log(` ${String(lineNum).padStart(3)} ${line.content}`);
141
124
  }
142
- }
143
- // Show the differences
144
- const maxDiffLine = Math.min(diffEnd, Math.max(localLines.length, remoteLines.length));
145
- for (let i = diffStart; i < maxDiffLine; i++) {
146
- const localLine = localLines[i];
147
- const remoteLine = remoteLines[i];
148
- if (localLine !== undefined && (remoteLine === undefined || localLine !== remoteLine)) {
149
- console.log(`${redBg} - ${String(i + 1).padStart(3)} ${localLine} ${reset}`);
150
- }
151
- if (remoteLine !== undefined && (localLine === undefined || localLine !== remoteLine)) {
152
- console.log(`${greenBg} + ${String(i + 1).padStart(3)} ${remoteLine} ${reset}`);
125
+ else if (line.type === 'remove') {
126
+ // Show local content being removed (red background)
127
+ console.log(`${redBg} - ${String(line.localLineNum).padStart(3)} ${line.content} ${reset}`);
153
128
  }
154
- }
155
- // Show context after (2 lines)
156
- const contextEnd = Math.min(localLines.length, diffEnd + 2);
157
- for (let i = diffEnd; i < contextEnd; i++) {
158
- if (localLines[i] !== undefined) {
159
- console.log(` ${String(i + 1).padStart(3)} ${localLines[i]}`);
129
+ else if (line.type === 'add') {
130
+ // Show remote content being added (green background)
131
+ console.log(`${greenBg} + ${String(line.remoteLineNum).padStart(3)} ${line.content} ${reset}`);
160
132
  }
161
133
  }
162
- // Show if there are more differences
163
- const totalDiffs = Math.abs(localLines.length - remoteLines.length) + (diffEnd - diffStart);
164
- if (totalDiffs > (diffEnd - diffStart)) {
165
- console.log(`${gray}... (${totalDiffs - (diffEnd - diffStart)} more differences)${reset}`);
134
+ // Show if there are more changes beyond what we're displaying
135
+ const totalChanges = fullDiff.filter(line => line.type !== 'context').length;
136
+ const displayedChanges = contextDiff.filter(line => line.type !== 'context').length;
137
+ if (totalChanges > displayedChanges) {
138
+ console.log(`${gray}... (${totalChanges - displayedChanges} more changes)${reset}`);
166
139
  }
167
140
  const answer = await new Promise((resolve) => {
168
141
  rl.question(`\nReplace local with remote? (y)es/(n)o/(a)ll/(q)uit: `, resolve);