newo 1.9.2 → 2.0.1

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 (66) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +68 -20
  3. package/dist/cli/commands/conversations.d.ts +3 -0
  4. package/dist/cli/commands/conversations.js +38 -0
  5. package/dist/cli/commands/help.d.ts +5 -0
  6. package/dist/cli/commands/help.js +50 -0
  7. package/dist/cli/commands/import-akb.d.ts +3 -0
  8. package/dist/cli/commands/import-akb.js +62 -0
  9. package/dist/cli/commands/list-customers.d.ts +3 -0
  10. package/dist/cli/commands/list-customers.js +13 -0
  11. package/dist/cli/commands/meta.d.ts +3 -0
  12. package/dist/cli/commands/meta.js +19 -0
  13. package/dist/cli/commands/pull-attributes.d.ts +3 -0
  14. package/dist/cli/commands/pull-attributes.js +16 -0
  15. package/dist/cli/commands/pull.d.ts +3 -0
  16. package/dist/cli/commands/pull.js +34 -0
  17. package/dist/cli/commands/push.d.ts +3 -0
  18. package/dist/cli/commands/push.js +39 -0
  19. package/dist/cli/commands/status.d.ts +3 -0
  20. package/dist/cli/commands/status.js +22 -0
  21. package/dist/cli/customer-selection.d.ts +23 -0
  22. package/dist/cli/customer-selection.js +110 -0
  23. package/dist/cli/errors.d.ts +9 -0
  24. package/dist/cli/errors.js +111 -0
  25. package/dist/cli.js +66 -463
  26. package/dist/fsutil.js +1 -1
  27. package/dist/sync/attributes.d.ts +7 -0
  28. package/dist/sync/attributes.js +90 -0
  29. package/dist/sync/conversations.d.ts +7 -0
  30. package/dist/sync/conversations.js +218 -0
  31. package/dist/sync/metadata.d.ts +8 -0
  32. package/dist/sync/metadata.js +124 -0
  33. package/dist/sync/projects.d.ts +13 -0
  34. package/dist/sync/projects.js +298 -0
  35. package/dist/sync/push.d.ts +7 -0
  36. package/dist/sync/push.js +171 -0
  37. package/dist/sync/skill-files.d.ts +43 -0
  38. package/dist/sync/skill-files.js +123 -0
  39. package/dist/sync/status.d.ts +6 -0
  40. package/dist/sync/status.js +247 -0
  41. package/dist/sync.d.ts +10 -8
  42. package/dist/sync.js +12 -1226
  43. package/dist/types.d.ts +0 -1
  44. package/package.json +2 -2
  45. package/src/cli/commands/conversations.ts +47 -0
  46. package/src/cli/commands/help.ts +50 -0
  47. package/src/cli/commands/import-akb.ts +71 -0
  48. package/src/cli/commands/list-customers.ts +14 -0
  49. package/src/cli/commands/meta.ts +26 -0
  50. package/src/cli/commands/pull-attributes.ts +23 -0
  51. package/src/cli/commands/pull.ts +43 -0
  52. package/src/cli/commands/push.ts +47 -0
  53. package/src/cli/commands/status.ts +30 -0
  54. package/src/cli/customer-selection.ts +135 -0
  55. package/src/cli/errors.ts +111 -0
  56. package/src/cli.ts +77 -471
  57. package/src/fsutil.ts +1 -1
  58. package/src/sync/attributes.ts +110 -0
  59. package/src/sync/conversations.ts +257 -0
  60. package/src/sync/metadata.ts +153 -0
  61. package/src/sync/projects.ts +372 -0
  62. package/src/sync/push.ts +200 -0
  63. package/src/sync/skill-files.ts +181 -0
  64. package/src/sync/status.ts +277 -0
  65. package/src/sync.ts +14 -1418
  66. package/src/types.ts +0 -1
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Project synchronization operations
3
+ */
4
+ import {
5
+ listProjects,
6
+ listAgents,
7
+ listFlowSkills,
8
+ listFlowEvents,
9
+ listFlowStates
10
+ } from '../api.js';
11
+ import {
12
+ ensureState,
13
+ writeFileSafe,
14
+ mapPath,
15
+ projectMetadataPath,
16
+ agentMetadataPath,
17
+ flowMetadataPath,
18
+ skillMetadataPath,
19
+ skillScriptPath,
20
+ skillFolderPath,
21
+ flowsYamlPath,
22
+ customerAttributesPath
23
+ } from '../fsutil.js';
24
+ import {
25
+ findSkillScriptFiles,
26
+ isContentDifferent,
27
+ askForOverwrite,
28
+ getExtensionForRunner
29
+ } from './skill-files.js';
30
+ import type { OverwriteChoice } from './skill-files.js';
31
+ import fs from 'fs-extra';
32
+ import { sha256, saveHashes } from '../hash.js';
33
+ import yaml from 'js-yaml';
34
+ import { generateFlowsYaml } from './metadata.js';
35
+ import { saveCustomerAttributes } from './attributes.js';
36
+ import type { AxiosInstance } from 'axios';
37
+ import type {
38
+ ProjectData,
39
+ ProjectMap,
40
+ LegacyProjectMap,
41
+ HashStore,
42
+ CustomerConfig,
43
+ ProjectMetadata,
44
+ AgentMetadata,
45
+ FlowMetadata,
46
+ SkillMetadata
47
+ } from '../types.js';
48
+
49
+ // Type guards for project map formats
50
+ export function isProjectMap(x: unknown): x is ProjectMap {
51
+ return typeof x === 'object' && x !== null && 'projects' in x;
52
+ }
53
+
54
+ export function isLegacyProjectMap(x: unknown): x is LegacyProjectMap {
55
+ return typeof x === 'object' && x !== null && 'projectId' in x && 'agents' in x;
56
+ }
57
+
58
+ /**
59
+ * Pull a single project and all its data
60
+ */
61
+ export async function pullSingleProject(
62
+ client: AxiosInstance,
63
+ customer: CustomerConfig,
64
+ projectId: string | null,
65
+ verbose: boolean = false,
66
+ silentOverwrite: boolean = false
67
+ ): Promise<void> {
68
+ if (verbose) console.log(`📋 Loading project list for customer ${customer.idn}...`);
69
+
70
+ const projects = projectId ?
71
+ [{ id: projectId, idn: 'unknown', title: 'Project' }] :
72
+ await listProjects(client);
73
+
74
+ if (projects.length === 0) {
75
+ console.log(`No projects found for customer ${customer.idn}`);
76
+ return;
77
+ }
78
+
79
+ await ensureState(customer.idn);
80
+
81
+ // Load existing mappings if they exist
82
+ let existingMap: ProjectMap = { projects: {} };
83
+ const mapFile = mapPath(customer.idn);
84
+ if (await fs.pathExists(mapFile)) {
85
+ const mapData = await fs.readJson(mapFile) as unknown;
86
+ if (isProjectMap(mapData)) {
87
+ existingMap = mapData;
88
+ } else if (isLegacyProjectMap(mapData)) {
89
+ // Convert legacy format to new format
90
+ existingMap = {
91
+ projects: {
92
+ [mapData.projectIdn || '']: mapData as ProjectData
93
+ }
94
+ };
95
+ }
96
+ }
97
+
98
+ const newHashes: HashStore = {};
99
+
100
+ // Progress tracking and overwrite control
101
+ let totalSkills = 0;
102
+ let processedSkills = 0;
103
+ let globalOverwriteAll = silentOverwrite;
104
+
105
+ // Count total skills for progress tracking
106
+ for (const project of projects) {
107
+ const agents = await listAgents(client, project.id);
108
+ for (const agent of agents) {
109
+ const flows = agent.flows || [];
110
+ for (const flow of flows) {
111
+ const skills = await listFlowSkills(client, flow.id);
112
+ totalSkills += skills.length;
113
+ }
114
+ }
115
+ }
116
+
117
+ if (verbose) console.log(`📊 Total skills to process: ${totalSkills}`);
118
+
119
+ for (const project of projects) {
120
+ if (verbose) console.log(`📁 Processing project: ${project.title} (${project.idn})`);
121
+
122
+ // Create project metadata
123
+ const projectMeta: ProjectMetadata = {
124
+ id: project.id,
125
+ idn: project.idn,
126
+ title: project.title,
127
+ description: project.description || '',
128
+ created_at: project.created_at || '',
129
+ updated_at: project.updated_at || ''
130
+ };
131
+
132
+ // Save project metadata
133
+ const projectMetaPath = projectMetadataPath(customer.idn, project.idn);
134
+ const projectMetaYaml = yaml.dump(projectMeta, { indent: 2, quotingType: '"', forceQuotes: false });
135
+ await writeFileSafe(projectMetaPath, projectMetaYaml);
136
+ newHashes[projectMetaPath] = sha256(projectMetaYaml);
137
+
138
+ const agents = await listAgents(client, project.id);
139
+ if (verbose) console.log(` 📋 Found ${agents.length} agents in project ${project.title}`);
140
+
141
+ const projectData: ProjectData = {
142
+ projectId: project.id,
143
+ projectIdn: project.idn,
144
+ agents: {}
145
+ };
146
+
147
+ for (const agent of agents) {
148
+ if (verbose) console.log(` 📁 Processing agent: ${agent.title} (${agent.idn})`);
149
+
150
+ // Create agent metadata
151
+ const agentMeta: AgentMetadata = {
152
+ id: agent.id,
153
+ idn: agent.idn,
154
+ title: agent.title || '',
155
+ description: agent.description || ''
156
+ };
157
+
158
+ // Save agent metadata
159
+ const agentMetaPath = agentMetadataPath(customer.idn, project.idn, agent.idn);
160
+ const agentMetaYaml = yaml.dump(agentMeta, { indent: 2, quotingType: '"', forceQuotes: false });
161
+ await writeFileSafe(agentMetaPath, agentMetaYaml);
162
+ newHashes[agentMetaPath] = sha256(agentMetaYaml);
163
+
164
+ projectData.agents[agent.idn] = {
165
+ id: agent.id,
166
+ flows: {}
167
+ };
168
+
169
+ const flows = agent.flows || [];
170
+ if (verbose && flows.length > 0) {
171
+ console.log(` 📋 Found ${flows.length} flows in agent ${agent.title}`);
172
+ }
173
+
174
+ for (const flow of flows) {
175
+ if (verbose) console.log(` 📁 Processing flow: ${flow.title} (${flow.idn})`);
176
+
177
+ // Get flow events and states for metadata
178
+ const [events, states] = await Promise.all([
179
+ listFlowEvents(client, flow.id).catch(() => []),
180
+ listFlowStates(client, flow.id).catch(() => [])
181
+ ]);
182
+
183
+ // Create flow metadata
184
+ const flowMeta: FlowMetadata = {
185
+ id: flow.id,
186
+ idn: flow.idn,
187
+ title: flow.title,
188
+ description: flow.description || '',
189
+ default_runner_type: flow.default_runner_type,
190
+ default_model: flow.default_model,
191
+ events,
192
+ state_fields: states
193
+ };
194
+
195
+ // Save flow metadata
196
+ const flowMetaPath = flowMetadataPath(customer.idn, project.idn, agent.idn, flow.idn);
197
+ const flowMetaYaml = yaml.dump(flowMeta, { indent: 2, quotingType: '"', forceQuotes: false });
198
+ await writeFileSafe(flowMetaPath, flowMetaYaml);
199
+ newHashes[flowMetaPath] = sha256(flowMetaYaml);
200
+
201
+ projectData.agents[agent.idn]!.flows[flow.idn] = {
202
+ id: flow.id,
203
+ skills: {}
204
+ };
205
+
206
+ const skills = await listFlowSkills(client, flow.id);
207
+ if (verbose) console.log(` 📋 Found ${skills.length} skills in flow ${flow.title}`);
208
+
209
+ for (const skill of skills) {
210
+ processedSkills++;
211
+ const progress = `[${processedSkills}/${totalSkills}]`;
212
+
213
+ if (verbose) {
214
+ console.log(` 📄 ${progress} Processing skill: ${skill.title} (${skill.idn})`);
215
+ } else {
216
+ // Show progress for non-verbose mode
217
+ if (processedSkills % 10 === 0 || processedSkills === totalSkills) {
218
+ process.stdout.write(`\r📄 Processing skills: ${processedSkills}/${totalSkills} (${Math.round(processedSkills/totalSkills*100)}%)`);
219
+ }
220
+ }
221
+
222
+ // Create skill metadata
223
+ const skillMeta: SkillMetadata = {
224
+ id: skill.id,
225
+ idn: skill.idn,
226
+ title: skill.title,
227
+ runner_type: skill.runner_type,
228
+ model: skill.model,
229
+ parameters: [...skill.parameters],
230
+ path: skill.path
231
+ };
232
+
233
+ // Save skill metadata
234
+ const skillMetaPath = skillMetadataPath(customer.idn, project.idn, agent.idn, flow.idn, skill.idn);
235
+ const skillMetaYaml = yaml.dump(skillMeta, { indent: 2, quotingType: '"', forceQuotes: false });
236
+ await writeFileSafe(skillMetaPath, skillMetaYaml);
237
+ newHashes[skillMetaPath] = sha256(skillMetaYaml);
238
+
239
+ // Handle skill script with IDN-based naming and overwrite detection
240
+ const scriptContent = skill.prompt_script || '';
241
+ const targetScriptPath = skillScriptPath(customer.idn, project.idn, agent.idn, flow.idn, skill.idn, skill.runner_type);
242
+ const folderPath = skillFolderPath(customer.idn, project.idn, agent.idn, flow.idn, skill.idn);
243
+
244
+ // Check for existing script files in the skill folder
245
+ const existingFiles = await findSkillScriptFiles(folderPath);
246
+ let shouldWrite = true;
247
+ let hasContentMatch = false;
248
+
249
+ if (existingFiles.length > 0) {
250
+ // Check if any existing file has the same content
251
+ hasContentMatch = existingFiles.some(file => !isContentDifferent(file.content, scriptContent));
252
+
253
+ if (hasContentMatch) {
254
+ // Content is the same - handle file naming
255
+ const matchingFile = existingFiles.find(file => !isContentDifferent(file.content, scriptContent));
256
+ const correctName = `${skill.idn}.${getExtensionForRunner(skill.runner_type)}`;
257
+
258
+ if (matchingFile && matchingFile.fileName !== correctName) {
259
+ // Remove old file and write with correct IDN-based name
260
+ await fs.remove(matchingFile.filePath);
261
+ if (verbose) console.log(` 🔄 Renamed ${matchingFile.fileName} → ${correctName}`);
262
+ } else if (matchingFile && matchingFile.fileName === correctName) {
263
+ // Already has correct name and content - skip completely
264
+ shouldWrite = false;
265
+ newHashes[matchingFile.filePath] = sha256(scriptContent);
266
+ if (verbose) console.log(` ✓ Content unchanged for ${skill.idn}, keeping existing file`);
267
+ }
268
+ } else if (!globalOverwriteAll) {
269
+ // Content is different, ask for overwrite unless global override is set
270
+ const existingFile = existingFiles[0]!;
271
+ const overwriteChoice: OverwriteChoice = await askForOverwrite(
272
+ skill.idn,
273
+ existingFile.fileName,
274
+ `${skill.idn}.${getExtensionForRunner(skill.runner_type)}`
275
+ );
276
+
277
+ if (overwriteChoice === 'quit') {
278
+ console.log('❌ Pull operation cancelled by user');
279
+ process.exit(0);
280
+ } else if (overwriteChoice === 'all') {
281
+ globalOverwriteAll = true;
282
+ // Continue with overwrite
283
+ for (const file of existingFiles) {
284
+ await fs.remove(file.filePath);
285
+ if (verbose) console.log(` 🗑️ Removed ${file.fileName}`);
286
+ }
287
+ } else if (overwriteChoice === 'yes') {
288
+ // Single overwrite
289
+ for (const file of existingFiles) {
290
+ await fs.remove(file.filePath);
291
+ if (verbose) console.log(` 🗑️ Removed ${file.fileName}`);
292
+ }
293
+ } else {
294
+ // User said no
295
+ shouldWrite = false;
296
+ if (verbose) console.log(` ⚠️ Skipped overwrite for ${skill.idn}`);
297
+ }
298
+ } else {
299
+ // Silent overwrite mode - remove existing files
300
+ for (const file of existingFiles) {
301
+ await fs.remove(file.filePath);
302
+ if (verbose) console.log(` 🔄 Silent overwrite: removed ${file.fileName}`);
303
+ }
304
+ }
305
+ }
306
+
307
+ if (shouldWrite) {
308
+ await writeFileSafe(targetScriptPath, scriptContent);
309
+ newHashes[targetScriptPath] = sha256(scriptContent);
310
+ const fileName = `${skill.idn}.${getExtensionForRunner(skill.runner_type)}`;
311
+ if (verbose) console.log(` ✓ Saved ${fileName}`);
312
+ }
313
+
314
+ projectData.agents[agent.idn]!.flows[flow.idn]!.skills[skill.idn] = skillMeta;
315
+ }
316
+ }
317
+ }
318
+
319
+ // Store project data in map
320
+ existingMap.projects[project.idn] = projectData;
321
+ }
322
+
323
+ // Clear progress line for non-verbose mode
324
+ if (!verbose && totalSkills > 0) {
325
+ console.log(`\n✅ Processed ${totalSkills} skills`);
326
+ }
327
+
328
+ // Save updated project map
329
+ await writeFileSafe(mapFile, JSON.stringify(existingMap, null, 2));
330
+
331
+ // Pull customer attributes as part of the project pull
332
+ try {
333
+ if (verbose) console.log(`🔍 Fetching customer attributes for ${customer.idn}...`);
334
+ const attributesContent = await saveCustomerAttributes(client, customer, verbose);
335
+
336
+ // Add attributes.yaml hash to the hash store
337
+ const attributesPath = customerAttributesPath(customer.idn);
338
+ newHashes[attributesPath] = sha256(attributesContent);
339
+
340
+ if (verbose) console.log(`✅ Customer attributes saved to newo_customers/${customer.idn}/attributes.yaml`);
341
+ } catch (error) {
342
+ console.warn(`⚠️ Failed to fetch customer attributes for ${customer.idn}: ${error instanceof Error ? error.message : String(error)}`);
343
+ if (verbose) console.warn('You can manually pull attributes using: newo pull-attributes');
344
+ }
345
+
346
+ // Generate flows.yaml and get its content for hashing
347
+ const flowsYamlContent = await generateFlowsYaml(existingMap, customer.idn, verbose);
348
+
349
+ // Add flows.yaml hash to the hash store
350
+ const flowsYamlFilePath = flowsYamlPath(customer.idn);
351
+ newHashes[flowsYamlFilePath] = sha256(flowsYamlContent);
352
+
353
+ // Save hashes (now including flows.yaml and attributes.yaml)
354
+ await saveHashes(newHashes, customer.idn);
355
+ }
356
+
357
+ /**
358
+ * Pull all projects for a customer
359
+ */
360
+ export async function pullAll(
361
+ client: AxiosInstance,
362
+ customer: CustomerConfig,
363
+ projectId: string | null = null,
364
+ verbose: boolean = false,
365
+ silentOverwrite: boolean = false
366
+ ): Promise<void> {
367
+ if (verbose) console.log(`🔄 Starting pull operation for customer ${customer.idn}...`);
368
+
369
+ await pullSingleProject(client, customer, projectId, verbose, silentOverwrite);
370
+
371
+ if (verbose) console.log(`✅ Pull completed for customer ${customer.idn}`);
372
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Push operations for changed files
3
+ */
4
+ import { updateSkill } from '../api.js';
5
+ import {
6
+ ensureState,
7
+ mapPath,
8
+ skillMetadataPath
9
+ } from '../fsutil.js';
10
+ import {
11
+ validateSkillFolder,
12
+ getSingleSkillFile
13
+ } from './skill-files.js';
14
+ import fs from 'fs-extra';
15
+ import { sha256, loadHashes, saveHashes } from '../hash.js';
16
+ import yaml from 'js-yaml';
17
+ import { generateFlowsYaml } from './metadata.js';
18
+ import { isProjectMap, isLegacyProjectMap } from './projects.js';
19
+ import { flowsYamlPath } from '../fsutil.js';
20
+ import type { AxiosInstance } from 'axios';
21
+ import type {
22
+ ProjectData,
23
+ ProjectMap,
24
+ CustomerConfig,
25
+ SkillMetadata
26
+ } from '../types.js';
27
+
28
+ /**
29
+ * Push changed files to NEWO platform
30
+ */
31
+ export async function pushChanged(client: AxiosInstance, customer: CustomerConfig, verbose: boolean = false): Promise<void> {
32
+ await ensureState(customer.idn);
33
+ if (!(await fs.pathExists(mapPath(customer.idn)))) {
34
+ console.log(`No map for customer ${customer.idn}. Run \`newo pull --customer ${customer.idn}\` first.`);
35
+ return;
36
+ }
37
+
38
+ if (verbose) console.log(`📋 Loading project mapping and hashes for customer ${customer.idn}...`);
39
+ const idMapData = await fs.readJson(mapPath(customer.idn)) as unknown;
40
+ const hashes = await loadHashes(customer.idn);
41
+ const newHashes = { ...hashes };
42
+ let pushed = 0;
43
+ let scanned = 0;
44
+ let metadataChanged = false;
45
+
46
+ // Handle both old single-project format and new multi-project format with type guards
47
+ const projects = isProjectMap(idMapData) && idMapData.projects
48
+ ? idMapData.projects
49
+ : isLegacyProjectMap(idMapData)
50
+ ? { '': idMapData as ProjectData }
51
+ : (() => { throw new Error('Invalid project map format'); })();
52
+
53
+ for (const [projectIdn, projectData] of Object.entries(projects)) {
54
+ if (verbose && projectIdn) console.log(`📁 Checking project: ${projectIdn}`);
55
+
56
+ for (const [agentIdn, agentObj] of Object.entries(projectData.agents)) {
57
+ if (verbose) console.log(` 📁 Checking agent: ${agentIdn}`);
58
+ for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
59
+ if (verbose) console.log(` 📁 Checking flow: ${flowIdn}`);
60
+ for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
61
+ scanned++;
62
+
63
+ // Validate skill folder has exactly one script file
64
+ const validation = await validateSkillFolder(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn);
65
+
66
+ if (!validation.isValid) {
67
+ // Show warnings and errors
68
+ validation.errors.forEach(error => {
69
+ console.error(`❌ ${error}`);
70
+ });
71
+ validation.warnings.forEach(warning => {
72
+ console.warn(`⚠️ ${warning}`);
73
+ });
74
+
75
+ if (validation.files.length > 1) {
76
+ console.warn(`⚠️ Skipping push for skill ${skillIdn} - multiple script files found:`);
77
+ validation.files.forEach(file => {
78
+ console.warn(` • ${file.fileName}`);
79
+ });
80
+ console.warn(` Please keep only one script file and try again.`);
81
+ }
82
+ continue;
83
+ }
84
+
85
+ // Get the single valid script file
86
+ const skillFile = await getSingleSkillFile(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn);
87
+ if (!skillFile) {
88
+ if (verbose) console.log(` ❌ No valid script file found for: ${skillIdn}`);
89
+ continue;
90
+ }
91
+
92
+ const content = skillFile.content;
93
+ const currentPath = skillFile.filePath;
94
+ const h = sha256(content);
95
+ const oldHash = hashes[currentPath];
96
+
97
+ if (oldHash !== h) {
98
+ if (verbose) console.log(`🔄 Script changed, updating: ${skillIdn} (${skillFile.fileName})`);
99
+
100
+ try {
101
+ // Create skill object for update
102
+ const skillObject = {
103
+ id: skillMeta.id,
104
+ title: skillMeta.title,
105
+ idn: skillMeta.idn,
106
+ prompt_script: content,
107
+ runner_type: skillMeta.runner_type,
108
+ model: skillMeta.model,
109
+ parameters: skillMeta.parameters,
110
+ path: skillMeta.path || undefined
111
+ };
112
+
113
+ await updateSkill(client, skillObject);
114
+ console.log(`↑ Pushed: ${skillIdn} (${skillMeta.title}) from ${skillFile.fileName}`);
115
+
116
+ newHashes[currentPath] = h;
117
+ pushed++;
118
+ } catch (error) {
119
+ console.error(`❌ Failed to push ${skillIdn}: ${error instanceof Error ? error.message : String(error)}`);
120
+ }
121
+ } else if (verbose) {
122
+ console.log(` ✓ No changes: ${skillIdn} (${skillFile.fileName})`);
123
+ }
124
+ }
125
+
126
+ // Check for metadata-only changes and push them separately
127
+ for (const [skillIdn] of Object.entries(flowObj.skills)) {
128
+ const metadataPath = projectIdn ?
129
+ skillMetadataPath(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn) :
130
+ skillMetadataPath(customer.idn, '', agentIdn, flowIdn, skillIdn);
131
+
132
+ if (await fs.pathExists(metadataPath)) {
133
+ const metadataContent = await fs.readFile(metadataPath, 'utf8');
134
+ const h = sha256(metadataContent);
135
+ const oldHash = hashes[metadataPath];
136
+
137
+ if (oldHash !== h) {
138
+ if (verbose) console.log(`🔄 Metadata-only change detected for ${skillIdn}, updating skill...`);
139
+
140
+ try {
141
+ // Load updated metadata
142
+ const updatedMetadata = yaml.load(metadataContent) as SkillMetadata;
143
+
144
+ // Get current script content using file validation
145
+ const skillFile = await getSingleSkillFile(customer.idn, projectIdn, agentIdn, flowIdn, skillIdn);
146
+ let scriptContent = '';
147
+
148
+ if (skillFile) {
149
+ scriptContent = skillFile.content;
150
+ } else {
151
+ console.warn(`⚠️ No valid script file found for metadata update: ${skillIdn}`);
152
+ continue;
153
+ }
154
+
155
+ // Create skill object with updated metadata
156
+ const skillObject = {
157
+ id: updatedMetadata.id,
158
+ title: updatedMetadata.title,
159
+ idn: updatedMetadata.idn,
160
+ prompt_script: scriptContent,
161
+ runner_type: updatedMetadata.runner_type,
162
+ model: updatedMetadata.model,
163
+ parameters: updatedMetadata.parameters,
164
+ path: updatedMetadata.path || undefined
165
+ };
166
+
167
+ await updateSkill(client, skillObject);
168
+ console.log(`↑ Pushed metadata update for skill: ${skillIdn} (${updatedMetadata.title})`);
169
+
170
+ newHashes[metadataPath] = h;
171
+ pushed++;
172
+ metadataChanged = true;
173
+
174
+ } catch (error) {
175
+ console.error(`❌ Failed to push metadata for ${skillIdn}: ${error instanceof Error ? error.message : String(error)}`);
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ if (verbose) console.log(`🔄 Scanned ${scanned} files, found ${pushed} changes`);
185
+
186
+ // Regenerate flows.yaml if metadata was changed
187
+ if (metadataChanged) {
188
+ if (verbose) console.log(`🔄 Regenerating flows.yaml due to metadata changes...`);
189
+ const flowsYamlContent = await generateFlowsYaml({ projects } as ProjectMap, customer.idn, verbose);
190
+
191
+ // Update hash for flows.yaml
192
+ const flowsYamlFilePath = flowsYamlPath(customer.idn);
193
+ newHashes[flowsYamlFilePath] = sha256(flowsYamlContent);
194
+ }
195
+
196
+ // Save updated hashes
197
+ await saveHashes(newHashes, customer.idn);
198
+
199
+ console.log(pushed ? `${pushed} file(s) pushed.` : 'No changes to push.');
200
+ }