newo 3.4.1 → 3.6.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 (69) hide show
  1. package/.env.example +5 -0
  2. package/CHANGELOG.md +31 -0
  3. package/dist/api.d.ts +18 -0
  4. package/dist/api.js +28 -0
  5. package/dist/cli/commands/create-attribute.js +1 -1
  6. package/dist/cli/commands/export.d.ts +3 -0
  7. package/dist/cli/commands/export.js +62 -0
  8. package/dist/cli/commands/help.js +54 -42
  9. package/dist/cli/commands/pull.js +38 -14
  10. package/dist/cli/commands/push.js +32 -32
  11. package/dist/cli/commands/status.js +46 -7
  12. package/dist/cli/commands/update-attribute.d.ts +3 -0
  13. package/dist/cli/commands/update-attribute.js +78 -0
  14. package/dist/cli-new/bootstrap.d.ts +7 -1
  15. package/dist/cli-new/bootstrap.js +11 -5
  16. package/dist/cli-new/di/tokens.d.ts +1 -0
  17. package/dist/cli-new/di/tokens.js +1 -0
  18. package/dist/cli.js +8 -0
  19. package/dist/domain/strategies/sync/ProjectSyncStrategy.d.ts +5 -0
  20. package/dist/domain/strategies/sync/ProjectSyncStrategy.js +97 -8
  21. package/dist/domain/strategies/sync/V2ProjectSyncStrategy.d.ts +80 -0
  22. package/dist/domain/strategies/sync/V2ProjectSyncStrategy.js +725 -0
  23. package/dist/env.d.ts +1 -0
  24. package/dist/env.js +1 -0
  25. package/dist/format/detect.d.ts +14 -0
  26. package/dist/format/detect.js +105 -0
  27. package/dist/format/extensions.d.ts +26 -0
  28. package/dist/format/extensions.js +45 -0
  29. package/dist/format/index.d.ts +11 -0
  30. package/dist/format/index.js +11 -0
  31. package/dist/format/paths-v2.d.ts +31 -0
  32. package/dist/format/paths-v2.js +104 -0
  33. package/dist/format/types.d.ts +28 -0
  34. package/dist/format/types.js +21 -0
  35. package/dist/format/v2-yaml.d.ts +143 -0
  36. package/dist/format/v2-yaml.js +222 -0
  37. package/dist/format/yaml-patch.d.ts +14 -0
  38. package/dist/format/yaml-patch.js +184 -0
  39. package/dist/fsutil.d.ts +10 -0
  40. package/dist/fsutil.js +25 -0
  41. package/dist/sync/attributes.js +3 -3
  42. package/dist/sync/skill-files.js +2 -2
  43. package/dist/types.d.ts +5 -0
  44. package/package.json +1 -1
  45. package/src/api.ts +64 -0
  46. package/src/cli/commands/create-attribute.ts +1 -1
  47. package/src/cli/commands/export.ts +78 -0
  48. package/src/cli/commands/help.ts +54 -42
  49. package/src/cli/commands/pull.ts +46 -15
  50. package/src/cli/commands/push.ts +38 -31
  51. package/src/cli/commands/status.ts +59 -9
  52. package/src/cli/commands/update-attribute.ts +82 -0
  53. package/src/cli-new/bootstrap.ts +19 -7
  54. package/src/cli-new/di/tokens.ts +1 -0
  55. package/src/cli.ts +10 -0
  56. package/src/domain/strategies/sync/ProjectSyncStrategy.ts +122 -8
  57. package/src/domain/strategies/sync/V2ProjectSyncStrategy.ts +1007 -0
  58. package/src/env.ts +2 -0
  59. package/src/format/detect.ts +123 -0
  60. package/src/format/extensions.ts +61 -0
  61. package/src/format/index.ts +66 -0
  62. package/src/format/paths-v2.ts +207 -0
  63. package/src/format/types.ts +40 -0
  64. package/src/format/v2-yaml.ts +345 -0
  65. package/src/format/yaml-patch.ts +208 -0
  66. package/src/fsutil.ts +37 -0
  67. package/src/sync/attributes.ts +3 -3
  68. package/src/sync/skill-files.ts +2 -2
  69. package/src/types.ts +6 -0
@@ -0,0 +1,1007 @@
1
+ /**
2
+ * V2ProjectSyncStrategy - Handles synchronization in newo_v2 format
3
+ *
4
+ * Uses the SAME V1 API endpoints as ProjectSyncStrategy but writes/reads
5
+ * files in the newo_v2 directory layout:
6
+ * {CustomerIdn}/
7
+ * import_version.txt
8
+ * {ProjectIdn}/
9
+ * {project_idn}.yaml
10
+ * agents/{AgentIdn}/
11
+ * agent.yaml
12
+ * flows/{FlowIdn}/
13
+ * {FlowIdn}.yaml (inline skill defs, events, state_fields)
14
+ * skills/{SkillIdn}.nsl|.nslg
15
+ */
16
+
17
+ import type {
18
+ ISyncStrategy,
19
+ PullOptions,
20
+ PullResult,
21
+ PushResult,
22
+ ChangeItem,
23
+ ValidationResult,
24
+ ValidationError,
25
+ StatusSummary
26
+ } from './ISyncStrategy.js';
27
+ import type { CustomerConfig, ILogger, HashStore } from '../../resources/common/types.js';
28
+ import type { AxiosInstance } from 'axios';
29
+ import type {
30
+ ProjectMeta,
31
+ Agent,
32
+ Flow,
33
+ Skill,
34
+ FlowEvent,
35
+ FlowState,
36
+ ProjectData,
37
+ ProjectMap,
38
+ SkillMetadata
39
+ } from '../../../types.js';
40
+ import type { LocalProjectData, LocalAgentData, LocalFlowData, LocalSkillData, ApiClientFactory } from './ProjectSyncStrategy.js';
41
+ import fs from 'fs-extra';
42
+ import {
43
+ listProjects,
44
+ listAgents,
45
+ listFlowSkills,
46
+ listFlowEvents,
47
+ listFlowStates,
48
+ updateSkill,
49
+ publishFlow,
50
+ getProjectAttributes,
51
+ getCustomerAttributes,
52
+ listLibraries,
53
+ updateLibrarySkill,
54
+ } from '../../../api.js';
55
+ import type { LibraryResponse } from '../../../api.js';
56
+ import {
57
+ ensureStateOnly,
58
+ writeFileSafe,
59
+ mapPath,
60
+ } from '../../../fsutil.js';
61
+ import { sha256, saveHashes, loadHashes } from '../../../hash.js';
62
+ import {
63
+ v2ImportVersionPath,
64
+ v2ProjectYamlPath,
65
+ v2AgentYamlPath,
66
+ v2FlowYamlPath,
67
+ v2SkillScriptPath,
68
+ v2SkillRelativePath,
69
+ v2ProjectAttributesPath,
70
+ v2CustomerAttributesPath,
71
+ v2AkbDir,
72
+ v2AkbPath,
73
+ v2LibraryYamlPath,
74
+ v2LibrarySkillScriptPath,
75
+ v2LibrarySkillRelativePath,
76
+ } from '../../../format/paths-v2.js';
77
+ import {
78
+ V2_IMPORT_VERSION,
79
+ } from '../../../format/types.js';
80
+ import {
81
+ generateV2FlowYaml,
82
+ generateV2ProjectYaml,
83
+ generateV2AgentYaml,
84
+ buildV2InlineSkill,
85
+ buildV2FlowEvent,
86
+ buildV2StateField,
87
+ type V2InlineSkill,
88
+ } from '../../../format/v2-yaml.js';
89
+ import { isContentDifferent } from '../../../sync/skill-files.js';
90
+ import yaml from 'js-yaml';
91
+ import { patchYamlToPyyaml } from '../../../format/yaml-patch.js';
92
+
93
+ /**
94
+ * V2ProjectSyncStrategy - same API, newo_v2 file layout
95
+ */
96
+ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalProjectData> {
97
+ readonly resourceType = 'projects';
98
+ readonly displayName = 'Projects (newo_v2)';
99
+
100
+ constructor(
101
+ private apiClientFactory: ApiClientFactory,
102
+ private logger: ILogger
103
+ ) {}
104
+
105
+ // ──────────────────────────────────────
106
+ // PULL
107
+ // ──────────────────────────────────────
108
+
109
+ async pull(customer: CustomerConfig, options: PullOptions = {}): Promise<PullResult<LocalProjectData>> {
110
+ const client = await this.apiClientFactory(customer, options.verbose ?? false);
111
+ const hashes: HashStore = {};
112
+ const projects: LocalProjectData[] = [];
113
+
114
+ this.logger.verbose(`[newo_v2] Loading project list for customer ${customer.idn}...`);
115
+
116
+ // Use V2 state init (no V1 projects/ dir)
117
+ await ensureStateOnly(customer.idn);
118
+
119
+ // Write import_version.txt marker
120
+ const versionPath = v2ImportVersionPath(customer.idn);
121
+ await writeFileSafe(versionPath, V2_IMPORT_VERSION);
122
+
123
+ // Write V2 customer attributes: attributes.yaml (sorted, with !enum ValueType.X)
124
+ try {
125
+ const custAttrs = await getCustomerAttributes(client, true);
126
+ const attrs = custAttrs.attributes || [];
127
+ if (attrs.length > 0) {
128
+ const attrYaml = formatV2AttributesYaml(attrs);
129
+ const custAttrPath = v2CustomerAttributesPath(customer.idn);
130
+ await writeFileSafe(custAttrPath, attrYaml);
131
+ hashes[custAttrPath] = sha256(attrYaml);
132
+ }
133
+ } catch {
134
+ this.logger.verbose(` Could not pull customer attributes`);
135
+ }
136
+
137
+ // Fetch projects from API (same V1 endpoints)
138
+ const apiProjects = options.projectId
139
+ ? [{ id: options.projectId, idn: 'unknown', title: 'Project' } as ProjectMeta]
140
+ : await listProjects(client);
141
+
142
+ if (apiProjects.length === 0) {
143
+ this.logger.info(`No projects found for customer ${customer.idn}`);
144
+ return { items: [], count: 0, hashes: {} };
145
+ }
146
+
147
+ // Load existing map for reference
148
+ let existingMap: ProjectMap = { projects: {} };
149
+ const mapFile = mapPath(customer.idn);
150
+ if (await fs.pathExists(mapFile)) {
151
+ try {
152
+ const mapData = await fs.readJson(mapFile);
153
+ if (mapData && typeof mapData === 'object' && 'projects' in mapData) {
154
+ existingMap = mapData as ProjectMap;
155
+ }
156
+ } catch {
157
+ // Start fresh
158
+ }
159
+ }
160
+
161
+ // Count total skills for progress
162
+ let totalSkills = 0;
163
+ let processedSkills = 0;
164
+
165
+ for (const project of apiProjects) {
166
+ const agents = await listAgents(client, project.id);
167
+ for (const agent of agents) {
168
+ const flows = agent.flows || [];
169
+ for (const flow of flows) {
170
+ const skills = await listFlowSkills(client, flow.id);
171
+ totalSkills += skills.length;
172
+ }
173
+ }
174
+ }
175
+
176
+ this.logger.verbose(`[newo_v2] Total skills to process: ${totalSkills}`);
177
+
178
+ // Process each project
179
+ for (const project of apiProjects) {
180
+ this.logger.verbose(`[newo_v2] Processing project: ${project.title} (${project.idn})`);
181
+
182
+ // Write V2 project YAML: {project_idn}.yaml
183
+ // The API returns registry_idn (not registry) - map to V2 field name
184
+ const projectYaml = generateV2ProjectYaml({
185
+ idn: project.idn,
186
+ name: project.title || project.idn,
187
+ version: (project as any).version || '1.0.0',
188
+ description: project.description || '',
189
+ is_auto_update_enabled: (project as any).is_auto_update_enabled ?? false,
190
+ registry: (project as any).registry_idn || (project as any).registry || '',
191
+ registry_item_idn: (project as any).registry_item_idn || '',
192
+ });
193
+ const projectYamlPath = v2ProjectYamlPath(customer.idn, project.idn);
194
+ await writeFileSafe(projectYamlPath, projectYaml);
195
+ hashes[projectYamlPath] = sha256(projectYaml);
196
+
197
+ const localProject: LocalProjectData = {
198
+ projectId: project.id,
199
+ projectIdn: project.idn,
200
+ metadata: {
201
+ id: project.id,
202
+ idn: project.idn,
203
+ title: project.title,
204
+ description: project.description || '',
205
+ created_at: project.created_at || '',
206
+ updated_at: project.updated_at || '',
207
+ },
208
+ agents: []
209
+ };
210
+
211
+ const agents = await listAgents(client, project.id);
212
+ this.logger.verbose(` Found ${agents.length} agents in project ${project.title}`);
213
+
214
+ const projectData: ProjectData = {
215
+ projectId: project.id,
216
+ projectIdn: project.idn,
217
+ agents: {}
218
+ };
219
+
220
+ // Process each agent
221
+ for (const agent of agents) {
222
+ const localAgent = await this.pullAgent(
223
+ client, customer, project, agent, hashes, options, () => {
224
+ processedSkills++;
225
+ if (!options.verbose && totalSkills > 0) {
226
+ if (processedSkills % 10 === 0 || processedSkills === totalSkills) {
227
+ this.logger.progress(processedSkills, totalSkills, '[newo_v2] Processing skills');
228
+ }
229
+ }
230
+ }
231
+ );
232
+ localProject.agents.push(localAgent);
233
+
234
+ // Build project data for map
235
+ projectData.agents[agent.idn] = {
236
+ id: agent.id,
237
+ flows: {}
238
+ };
239
+
240
+ for (const flow of localAgent.flows) {
241
+ projectData.agents[agent.idn]!.flows[flow.idn] = {
242
+ id: flow.id,
243
+ skills: {}
244
+ };
245
+
246
+ for (const skill of flow.skills) {
247
+ projectData.agents[agent.idn]!.flows[flow.idn]!.skills[skill.idn] = skill.metadata;
248
+ }
249
+ }
250
+ }
251
+
252
+ // Pull libraries for this project
253
+ try {
254
+ const libraries = await listLibraries(client, project.id);
255
+ if (libraries.length > 0) {
256
+ this.logger.verbose(` Found ${libraries.length} libraries in project ${project.idn}`);
257
+ projectData.libraries = {};
258
+
259
+ for (const lib of libraries) {
260
+ await this.pullLibrary(client, customer, project, lib, hashes, options);
261
+ projectData.libraries[lib.idn] = {
262
+ id: lib.id,
263
+ skills: {}
264
+ };
265
+ for (const skill of lib.skills) {
266
+ projectData.libraries[lib.idn]!.skills[skill.idn] = {
267
+ id: skill.id,
268
+ idn: skill.idn,
269
+ title: skill.title,
270
+ runner_type: skill.runner_type,
271
+ model: skill.model,
272
+ parameters: [...skill.parameters],
273
+ path: skill.path
274
+ };
275
+ }
276
+ }
277
+ }
278
+ } catch {
279
+ this.logger.verbose(` Could not pull libraries for project ${project.idn}`);
280
+ }
281
+
282
+ // Write V2 project attributes: {project_idn}/attributes.yaml
283
+ try {
284
+ const projAttrs = await getProjectAttributes(client, project.id, true);
285
+ const attrs = projAttrs.attributes || [];
286
+ if (attrs.length > 0) {
287
+ const attrYaml = formatV2AttributesYaml(attrs);
288
+ const attrPath = v2ProjectAttributesPath(customer.idn, project.idn);
289
+ await writeFileSafe(attrPath, attrYaml);
290
+ hashes[attrPath] = sha256(attrYaml);
291
+ }
292
+ } catch {
293
+ this.logger.verbose(` Could not pull attributes for project ${project.idn}`);
294
+ }
295
+
296
+ existingMap.projects[project.idn] = projectData;
297
+ projects.push(localProject);
298
+ }
299
+
300
+ // Write AKB stub files for all agents: akb/{AgentIdn}.yaml
301
+ // V2 format creates an empty [] file for every agent persona
302
+ const akbDirPath = v2AkbDir(customer.idn);
303
+ await fs.ensureDir(akbDirPath);
304
+ for (const project of projects) {
305
+ for (const agent of project.agents) {
306
+ const akbFilePath = v2AkbPath(customer.idn, agent.idn);
307
+ if (!(await fs.pathExists(akbFilePath))) {
308
+ await writeFileSafe(akbFilePath, '[]\n');
309
+ }
310
+ // Don't overwrite existing AKB files that may have content from AkbSyncStrategy
311
+ }
312
+ }
313
+
314
+ // Save updated project map
315
+ await writeFileSafe(mapFile, JSON.stringify(existingMap, null, 2));
316
+
317
+ // Save hashes
318
+ await saveHashes(hashes, customer.idn);
319
+
320
+ return {
321
+ items: projects,
322
+ count: projects.length,
323
+ hashes
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Pull a single agent in V2 format
329
+ */
330
+ private async pullAgent(
331
+ client: AxiosInstance,
332
+ customer: CustomerConfig,
333
+ project: ProjectMeta,
334
+ agent: Agent,
335
+ hashes: HashStore,
336
+ options: PullOptions,
337
+ onSkillProcessed: () => void
338
+ ): Promise<LocalAgentData> {
339
+ this.logger.verbose(` [newo_v2] Processing agent: ${agent.title} (${agent.idn})`);
340
+
341
+ // Write V2 agent YAML: agents/{AgentIdn}/agent.yaml
342
+ // Preserve exact API values (null title stays null, "" description stays "")
343
+ const agentYaml = generateV2AgentYaml({
344
+ idn: agent.idn,
345
+ title: agent.title ?? null,
346
+ description: agent.description ?? null,
347
+ });
348
+ const agentYamlFilePath = v2AgentYamlPath(customer.idn, project.idn, agent.idn);
349
+ await writeFileSafe(agentYamlFilePath, agentYaml);
350
+ hashes[agentYamlFilePath] = sha256(agentYaml);
351
+
352
+ const localAgent: LocalAgentData = {
353
+ id: agent.id,
354
+ idn: agent.idn,
355
+ metadata: {
356
+ id: agent.id,
357
+ idn: agent.idn,
358
+ title: agent.title || '',
359
+ description: agent.description || '',
360
+ },
361
+ flows: []
362
+ };
363
+
364
+ const flows = agent.flows || [];
365
+ this.logger.verbose(` Found ${flows.length} flows in agent ${agent.title}`);
366
+
367
+ for (const flow of flows) {
368
+ const localFlow = await this.pullFlow(
369
+ client, customer, project, agent, flow, hashes, options, onSkillProcessed
370
+ );
371
+ localAgent.flows.push(localFlow);
372
+ }
373
+
374
+ return localAgent;
375
+ }
376
+
377
+ /**
378
+ * Pull a single flow in V2 format
379
+ *
380
+ * In V2, the flow YAML contains inline skill definitions, events, and state_fields.
381
+ * Skills are written to flows/{FlowIdn}/skills/{SkillIdn}.nsl|.nslg
382
+ */
383
+ private async pullFlow(
384
+ client: AxiosInstance,
385
+ customer: CustomerConfig,
386
+ project: ProjectMeta,
387
+ agent: Agent,
388
+ flow: Flow,
389
+ hashes: HashStore,
390
+ options: PullOptions,
391
+ onSkillProcessed: () => void
392
+ ): Promise<LocalFlowData> {
393
+ this.logger.verbose(` [newo_v2] Processing flow: ${flow.title} (${flow.idn})`);
394
+
395
+ // Get flow events and states
396
+ const [events, states] = await Promise.all([
397
+ listFlowEvents(client, flow.id).catch(() => [] as FlowEvent[]),
398
+ listFlowStates(client, flow.id).catch(() => [] as FlowState[])
399
+ ]);
400
+
401
+ // Process skills
402
+ const skills = await listFlowSkills(client, flow.id);
403
+ this.logger.verbose(` Found ${skills.length} skills in flow ${flow.title}`);
404
+
405
+ // Build V2 inline skill definitions
406
+ const v2Skills: V2InlineSkill[] = [];
407
+ const localFlow: LocalFlowData = {
408
+ id: flow.id,
409
+ idn: flow.idn,
410
+ metadata: {
411
+ id: flow.id,
412
+ idn: flow.idn,
413
+ title: flow.title,
414
+ description: flow.description || '',
415
+ default_runner_type: flow.default_runner_type,
416
+ default_model: flow.default_model,
417
+ events,
418
+ state_fields: states
419
+ },
420
+ skills: []
421
+ };
422
+
423
+ for (const skill of skills) {
424
+ const localSkill = await this.pullSkill(
425
+ customer, project, agent, flow, skill, hashes, options
426
+ );
427
+ localFlow.skills.push(localSkill);
428
+ onSkillProcessed();
429
+
430
+ // Build inline skill definition for flow YAML
431
+ const relPath = v2SkillRelativePath(flow.idn, skill.idn, skill.runner_type);
432
+ v2Skills.push(buildV2InlineSkill(
433
+ skill.idn,
434
+ skill.title || '',
435
+ skill.runner_type,
436
+ skill.model?.model_idn || flow.default_model?.model_idn || '',
437
+ skill.model?.provider_idn || flow.default_model?.provider_idn || '',
438
+ skill.parameters.map(p => ({
439
+ name: p.name,
440
+ default_value: p.default_value ?? '',
441
+ })),
442
+ relPath
443
+ ));
444
+ }
445
+
446
+ // Build V2 events
447
+ const v2Events = events.map(e => buildV2FlowEvent(
448
+ e.idn,
449
+ e.skill_selector || 'skill_idn',
450
+ e.skill_idn || null,
451
+ e.state_idn || null,
452
+ e.integration_idn || null,
453
+ e.connector_idn || null,
454
+ e.interrupt_mode || 'queue'
455
+ ));
456
+
457
+ // Build V2 state fields
458
+ const v2States = states.map(s => buildV2StateField(
459
+ s.idn,
460
+ s.title || '',
461
+ s.default_value ?? '',
462
+ s.scope || 'user'
463
+ ));
464
+
465
+ // Write V2 flow YAML: flows/{FlowIdn}/{FlowIdn}.yaml
466
+ const flowYaml = generateV2FlowYaml(
467
+ flow.idn,
468
+ flow.title || flow.idn,
469
+ flow.description ?? null,
470
+ flow.default_runner_type || 'guidance',
471
+ flow.default_model?.provider_idn || '',
472
+ flow.default_model?.model_idn || '',
473
+ v2Skills,
474
+ v2Events,
475
+ v2States
476
+ );
477
+ const flowYamlFilePath = v2FlowYamlPath(customer.idn, project.idn, agent.idn, flow.idn);
478
+ await writeFileSafe(flowYamlFilePath, flowYaml);
479
+ hashes[flowYamlFilePath] = sha256(flowYaml);
480
+
481
+ return localFlow;
482
+ }
483
+
484
+ /**
485
+ * Pull a single skill script in V2 format
486
+ *
487
+ * Script goes to: flows/{FlowIdn}/skills/{SkillIdn}.nsl|.nslg
488
+ * No separate metadata.yaml - metadata is inline in the flow YAML
489
+ */
490
+ private async pullSkill(
491
+ customer: CustomerConfig,
492
+ project: ProjectMeta,
493
+ agent: Agent,
494
+ flow: Flow,
495
+ skill: Skill,
496
+ hashes: HashStore,
497
+ options: PullOptions
498
+ ): Promise<LocalSkillData> {
499
+ this.logger.verbose(` [newo_v2] Processing skill: ${skill.title} (${skill.idn})`);
500
+
501
+ const scriptContent = skill.prompt_script || '';
502
+ const targetPath = v2SkillScriptPath(
503
+ customer.idn, project.idn, agent.idn, flow.idn, skill.idn, skill.runner_type
504
+ );
505
+
506
+ // Check for existing file and handle overwrites
507
+ let shouldWrite = true;
508
+ if (await fs.pathExists(targetPath)) {
509
+ const existingContent = await fs.readFile(targetPath, 'utf8');
510
+ if (!isContentDifferent(existingContent, scriptContent)) {
511
+ shouldWrite = false;
512
+ hashes[targetPath] = sha256(scriptContent);
513
+ } else if (!options.silentOverwrite) {
514
+ // In non-silent mode, we overwrite (interactive mode handled in CLI layer)
515
+ shouldWrite = true;
516
+ }
517
+ }
518
+
519
+ if (shouldWrite) {
520
+ await writeFileSafe(targetPath, scriptContent);
521
+ hashes[targetPath] = sha256(scriptContent);
522
+ }
523
+
524
+ const skillMeta: SkillMetadata = {
525
+ id: skill.id,
526
+ idn: skill.idn,
527
+ title: skill.title,
528
+ runner_type: skill.runner_type,
529
+ model: skill.model,
530
+ parameters: [...skill.parameters],
531
+ path: skill.path
532
+ };
533
+
534
+ return {
535
+ id: skill.id,
536
+ idn: skill.idn,
537
+ metadata: skillMeta,
538
+ scriptPath: targetPath,
539
+ scriptContent
540
+ };
541
+ }
542
+
543
+ /**
544
+ * Pull a library and its skills in V2 format
545
+ *
546
+ * Writes:
547
+ * {project}/libraries/{lib}/{lib}.yaml (with inline skill list)
548
+ * {project}/libraries/{lib}/skills/{skill}.nsl|.nslg
549
+ */
550
+ private async pullLibrary(
551
+ _client: AxiosInstance,
552
+ customer: CustomerConfig,
553
+ project: ProjectMeta,
554
+ lib: LibraryResponse,
555
+ hashes: HashStore,
556
+ _options: PullOptions
557
+ ): Promise<void> {
558
+ this.logger.verbose(` [newo_v2] Processing library: ${lib.idn} (${lib.skills.length} skills)`);
559
+
560
+ // Build V2 inline skill definitions for library YAML
561
+ const v2Skills: V2InlineSkill[] = [];
562
+ for (const skill of lib.skills) {
563
+ const relPath = v2LibrarySkillRelativePath(project.idn, lib.idn, skill.idn, skill.runner_type);
564
+ v2Skills.push(buildV2InlineSkill(
565
+ skill.idn,
566
+ skill.title || '',
567
+ skill.runner_type,
568
+ skill.model?.model_idn || '',
569
+ skill.model?.provider_idn || '',
570
+ skill.parameters.map(p => ({
571
+ name: p.name,
572
+ default_value: p.default_value ?? '',
573
+ })),
574
+ relPath
575
+ ));
576
+ }
577
+
578
+ // Sort skills same as flows
579
+ const { sortV2Skills, sortV2Parameters } = await import('../../../format/v2-yaml.js');
580
+ const sortedSkills = sortV2Skills(v2Skills).map(s => ({
581
+ ...s,
582
+ parameters: sortV2Parameters(s.parameters),
583
+ }));
584
+
585
+ // Write library YAML: libraries/{lib}/{lib}.yaml
586
+ const libDef = {
587
+ library: {
588
+ idn: lib.idn,
589
+ skills: sortedSkills,
590
+ }
591
+ };
592
+ const libYaml = yaml.dump(libDef, { indent: 2, lineWidth: -1, noRefs: true, sortKeys: false });
593
+ const libYamlPath = v2LibraryYamlPath(customer.idn, project.idn, lib.idn);
594
+ await writeFileSafe(libYamlPath, libYaml);
595
+ hashes[libYamlPath] = sha256(libYaml);
596
+
597
+ // Write skill scripts
598
+ for (const skill of lib.skills) {
599
+ const scriptContent = skill.prompt_script || '';
600
+ const scriptPath = v2LibrarySkillScriptPath(
601
+ customer.idn, project.idn, lib.idn, skill.idn, skill.runner_type
602
+ );
603
+
604
+ let shouldWrite = true;
605
+ if (await fs.pathExists(scriptPath)) {
606
+ const existing = await fs.readFile(scriptPath, 'utf8');
607
+ if (!isContentDifferent(existing, scriptContent)) {
608
+ shouldWrite = false;
609
+ hashes[scriptPath] = sha256(scriptContent);
610
+ }
611
+ }
612
+
613
+ if (shouldWrite) {
614
+ await writeFileSafe(scriptPath, scriptContent);
615
+ hashes[scriptPath] = sha256(scriptContent);
616
+ }
617
+ }
618
+ }
619
+
620
+ // ──────────────────────────────────────
621
+ // PUSH
622
+ // ──────────────────────────────────────
623
+
624
+ async push(customer: CustomerConfig, changes?: ChangeItem<LocalProjectData>[]): Promise<PushResult> {
625
+ const result: PushResult = { created: 0, updated: 0, deleted: 0, errors: [] };
626
+
627
+ if (!changes) {
628
+ changes = await this.getChanges(customer);
629
+ }
630
+
631
+ if (changes.length === 0) {
632
+ return result;
633
+ }
634
+
635
+ const client = await this.apiClientFactory(customer, false);
636
+ const existingHashes = await loadHashes(customer.idn);
637
+ const newHashes = { ...existingHashes };
638
+
639
+ // Load project map
640
+ const mapFile = mapPath(customer.idn);
641
+ if (!(await fs.pathExists(mapFile))) {
642
+ result.errors.push('No project map found. Run pull first.');
643
+ return result;
644
+ }
645
+
646
+ const mapData = await fs.readJson(mapFile) as ProjectMap;
647
+
648
+ for (const change of changes) {
649
+ try {
650
+ if (change.operation === 'modified') {
651
+ // Detect if this is a library skill or flow skill by path
652
+ const isLibrary = change.path.includes('/libraries/');
653
+ const count = isLibrary
654
+ ? await this.pushV2LibrarySkillUpdate(client, change, mapData, newHashes)
655
+ : await this.pushV2SkillUpdate(client, change, mapData, newHashes);
656
+ result.updated += count;
657
+ }
658
+ } catch (error) {
659
+ result.errors.push(
660
+ `Failed to push ${change.path}: ${error instanceof Error ? error.message : String(error)}`
661
+ );
662
+ }
663
+ }
664
+
665
+ await saveHashes(newHashes, customer.idn);
666
+
667
+ if (result.created > 0 || result.updated > 0) {
668
+ await this.publishAllFlows(client, mapData);
669
+ }
670
+
671
+ return result;
672
+ }
673
+
674
+ /**
675
+ * Push a V2 skill update
676
+ *
677
+ * V2 path: newo_customers/{cust}/{proj}/agents/{agent}/flows/{flow}/skills/{skill}.nsl
678
+ */
679
+ private async pushV2SkillUpdate(
680
+ client: AxiosInstance,
681
+ change: ChangeItem<LocalProjectData>,
682
+ mapData: ProjectMap,
683
+ newHashes: HashStore
684
+ ): Promise<number> {
685
+ // Parse V2 path to extract entity hierarchy
686
+ // Path: .../newo_customers/{cust}/{proj}/agents/{agent}/flows/{flow}/skills/{skillFile}
687
+ const pathParts = change.path.split('/');
688
+ const skillFileName = pathParts[pathParts.length - 1] || '';
689
+ const skillIdn = skillFileName.replace(/\.(nsl|nslg|jinja|guidance)$/, '');
690
+ // skills/ -> flow/ -> flows/ -> agent/ -> agents/ -> project/
691
+ const flowIdn = pathParts[pathParts.length - 3] || '';
692
+ const agentIdn = pathParts[pathParts.length - 5] || '';
693
+ const projectIdn = pathParts[pathParts.length - 7] || '';
694
+
695
+ // Look up skill in map
696
+ const projectData = mapData.projects[projectIdn];
697
+ const agentData = projectData?.agents[agentIdn];
698
+ const flowData = agentData?.flows[flowIdn];
699
+ const skillData = flowData?.skills[skillIdn];
700
+
701
+ if (!skillData) {
702
+ throw new Error(`Skill ${skillIdn} not found in project map (path: ${change.path})`);
703
+ }
704
+
705
+ // Read updated script content
706
+ const content = await fs.readFile(change.path, 'utf8');
707
+
708
+ // Update via V1 API
709
+ await updateSkill(client, {
710
+ id: skillData.id,
711
+ title: skillData.title,
712
+ idn: skillData.idn,
713
+ prompt_script: content,
714
+ runner_type: skillData.runner_type,
715
+ model: skillData.model,
716
+ parameters: skillData.parameters,
717
+ path: skillData.path
718
+ });
719
+
720
+ newHashes[change.path] = sha256(content);
721
+ this.logger.info(`[newo_v2] Pushed: ${skillIdn}`);
722
+ return 1;
723
+ }
724
+
725
+ /**
726
+ * Push a V2 library skill update
727
+ * Path: .../newo_customers/{cust}/{proj}/libraries/{lib}/skills/{skillFile}
728
+ */
729
+ private async pushV2LibrarySkillUpdate(
730
+ client: AxiosInstance,
731
+ change: ChangeItem<LocalProjectData>,
732
+ mapData: ProjectMap,
733
+ newHashes: HashStore
734
+ ): Promise<number> {
735
+ const pathParts = change.path.split('/');
736
+ const skillFileName = pathParts[pathParts.length - 1] || '';
737
+ const skillIdn = skillFileName.replace(/\.(nsl|nslg|jinja|guidance)$/, '');
738
+ // skills/ -> lib/ -> libraries/ -> project/
739
+ const libIdn = pathParts[pathParts.length - 3] || '';
740
+ const projectIdn = pathParts[pathParts.length - 5] || '';
741
+
742
+ const projectData = mapData.projects[projectIdn];
743
+ const libData = projectData?.libraries?.[libIdn];
744
+ const skillData = libData?.skills[skillIdn];
745
+
746
+ if (!skillData || !libData) {
747
+ throw new Error(`Library skill ${skillIdn} not found in project map (path: ${change.path})`);
748
+ }
749
+
750
+ const content = await fs.readFile(change.path, 'utf8');
751
+
752
+ await updateLibrarySkill(client, libData.id, skillData.id, {
753
+ prompt_script: content,
754
+ });
755
+
756
+ newHashes[change.path] = sha256(content);
757
+ this.logger.info(`[newo_v2] Pushed library skill: ${libIdn}/${skillIdn}`);
758
+ return 1;
759
+ }
760
+
761
+ /**
762
+ * Publish all flows
763
+ */
764
+ private async publishAllFlows(client: AxiosInstance, mapData: ProjectMap): Promise<void> {
765
+ for (const projectData of Object.values(mapData.projects)) {
766
+ for (const agentData of Object.values(projectData.agents)) {
767
+ for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
768
+ if (flowData.id) {
769
+ try {
770
+ await publishFlow(client, flowData.id, {
771
+ version: '1.0',
772
+ description: 'Published via NEWO CLI (newo_v2)',
773
+ type: 'public'
774
+ });
775
+ this.logger.verbose(`[newo_v2] Published flow: ${flowIdn}`);
776
+ } catch {
777
+ this.logger.warn(`[newo_v2] Failed to publish flow ${flowIdn}`);
778
+ }
779
+ }
780
+ }
781
+ }
782
+ }
783
+ }
784
+
785
+ // ──────────────────────────────────────
786
+ // STATUS / CHANGES
787
+ // ──────────────────────────────────────
788
+
789
+ async getChanges(customer: CustomerConfig): Promise<ChangeItem<LocalProjectData>[]> {
790
+ const changes: ChangeItem<LocalProjectData>[] = [];
791
+
792
+ const mapFile = mapPath(customer.idn);
793
+ if (!(await fs.pathExists(mapFile))) {
794
+ return changes;
795
+ }
796
+
797
+ const hashes = await loadHashes(customer.idn);
798
+ const mapData = await fs.readJson(mapFile) as ProjectMap;
799
+
800
+ // Scan V2 directory structure for changed skill scripts
801
+ for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
802
+ // Flow skills
803
+ for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
804
+ for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
805
+ for (const [skillIdn, skillMeta] of Object.entries(flowData.skills)) {
806
+ const scriptPath = v2SkillScriptPath(
807
+ customer.idn, projectIdn, agentIdn, flowIdn, skillIdn,
808
+ skillMeta.runner_type
809
+ );
810
+
811
+ if (await fs.pathExists(scriptPath)) {
812
+ const content = await fs.readFile(scriptPath, 'utf8');
813
+ const currentHash = sha256(content);
814
+ const storedHash = hashes[scriptPath];
815
+
816
+ if (storedHash !== currentHash) {
817
+ changes.push({
818
+ item: {} as LocalProjectData,
819
+ operation: 'modified',
820
+ path: scriptPath
821
+ });
822
+ }
823
+ }
824
+ }
825
+ }
826
+ }
827
+
828
+ // Library skills
829
+ if (projectData.libraries) {
830
+ for (const [libIdn, libData] of Object.entries(projectData.libraries)) {
831
+ for (const [skillIdn, skillMeta] of Object.entries(libData.skills)) {
832
+ const scriptPath = v2LibrarySkillScriptPath(
833
+ customer.idn, projectIdn, libIdn, skillIdn,
834
+ skillMeta.runner_type
835
+ );
836
+
837
+ if (await fs.pathExists(scriptPath)) {
838
+ const content = await fs.readFile(scriptPath, 'utf8');
839
+ const currentHash = sha256(content);
840
+ const storedHash = hashes[scriptPath];
841
+
842
+ if (storedHash !== currentHash) {
843
+ changes.push({
844
+ item: {} as LocalProjectData,
845
+ operation: 'modified',
846
+ path: scriptPath
847
+ });
848
+ }
849
+ }
850
+ }
851
+ }
852
+ }
853
+ }
854
+
855
+ return changes;
856
+ }
857
+
858
+ async validate(customer: CustomerConfig, _items: LocalProjectData[]): Promise<ValidationResult> {
859
+ const errors: ValidationError[] = [];
860
+
861
+ const mapFile = mapPath(customer.idn);
862
+ if (!(await fs.pathExists(mapFile))) {
863
+ errors.push({
864
+ field: 'projectMap',
865
+ message: 'No project map found. Run pull first.'
866
+ });
867
+ return { valid: false, errors };
868
+ }
869
+
870
+ const mapData = await fs.readJson(mapFile) as ProjectMap;
871
+
872
+ // Validate V2 skill files exist
873
+ for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
874
+ for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
875
+ for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
876
+ for (const [skillIdn, skillMeta] of Object.entries(flowData.skills)) {
877
+ const scriptPath = v2SkillScriptPath(
878
+ customer.idn, projectIdn, agentIdn, flowIdn, skillIdn,
879
+ skillMeta.runner_type
880
+ );
881
+
882
+ if (!(await fs.pathExists(scriptPath))) {
883
+ errors.push({
884
+ field: `skill.${skillIdn}`,
885
+ message: `Script file not found: ${scriptPath}`,
886
+ path: scriptPath
887
+ });
888
+ }
889
+ }
890
+ }
891
+ }
892
+ }
893
+
894
+ return { valid: errors.length === 0, errors };
895
+ }
896
+
897
+ async getStatus(customer: CustomerConfig): Promise<StatusSummary> {
898
+ const changes = await this.getChanges(customer);
899
+
900
+ return {
901
+ resourceType: this.resourceType,
902
+ displayName: this.displayName,
903
+ changedCount: changes.length,
904
+ changes: changes.map(c => ({
905
+ path: c.path,
906
+ operation: c.operation
907
+ }))
908
+ };
909
+ }
910
+ }
911
+
912
+ // ── V2 Attributes Formatting ──
913
+
914
+ /**
915
+ * Map V1 API value_type to V2 export format
916
+ * V1 API: "string", "bool", "AttributeValueTypes.string", etc.
917
+ * V2 export: "ValueType.STRING", "ValueType.BOOL", etc.
918
+ */
919
+ function toV2ValueType(apiValueType: string): string {
920
+ // Already in V2 format
921
+ if (apiValueType.startsWith('ValueType.')) return apiValueType;
922
+
923
+ // Strip AttributeValueTypes. prefix if present
924
+ const raw = apiValueType.replace(/^AttributeValueTypes\./, '');
925
+
926
+ const mapping: Record<string, string> = {
927
+ 'string': 'ValueType.STRING',
928
+ 'bool': 'ValueType.BOOL',
929
+ 'number': 'ValueType.NUMBER',
930
+ 'enum': 'ValueType.ENUM',
931
+ 'json': 'ValueType.JSON',
932
+ };
933
+
934
+ return mapping[raw.toLowerCase()] || `ValueType.${raw.toUpperCase()}`;
935
+ }
936
+
937
+ /**
938
+ * Format attributes as V2 YAML with:
939
+ * - Sorted by idn alphabetically
940
+ * - value_type as !enum "ValueType.X"
941
+ * - Proper quoting
942
+ */
943
+ function formatV2AttributesYaml(attrs: Array<{
944
+ idn: string;
945
+ value: any;
946
+ title?: string | undefined;
947
+ description?: string | undefined;
948
+ group?: string | undefined;
949
+ is_hidden?: boolean | undefined;
950
+ possible_values?: any[] | undefined;
951
+ value_type?: string | undefined;
952
+ }>): string {
953
+ // Sort alphabetically by idn
954
+ const sorted = [...attrs].sort((a, b) => a.idn.localeCompare(b.idn));
955
+
956
+ const cleaned = sorted.map(a => ({
957
+ idn: a.idn,
958
+ value: a.value,
959
+ title: a.title || '',
960
+ description: a.description || '',
961
+ group: a.group || '',
962
+ is_hidden: a.is_hidden ?? false,
963
+ possible_values: a.possible_values || [],
964
+ value_type: new V2EnumValue(toV2ValueType(a.value_type || 'string')),
965
+ }));
966
+
967
+ const enumType = new yaml.Type('!enum', {
968
+ kind: 'scalar',
969
+ instanceOf: V2EnumValue,
970
+ resolve: () => true,
971
+ construct: (data: string) => new V2EnumValue(data),
972
+ represent: (data: unknown) => data instanceof V2EnumValue ? data.value : String(data),
973
+ });
974
+ const schema = yaml.DEFAULT_SCHEMA.extend([enumType]);
975
+
976
+ // Use lineWidth: -1 to prevent folding multiline strings (preserve |- literal block style)
977
+ const rawYaml = yaml.dump({ attributes: cleaned }, {
978
+ indent: 2,
979
+ quotingType: '"',
980
+ forceQuotes: false,
981
+ lineWidth: -1,
982
+ noRefs: true,
983
+ sortKeys: false,
984
+ schema,
985
+ });
986
+
987
+ // Fix !enum quoting: js-yaml outputs `!enum ValueType.STRING` but V2 ZIP uses `!enum "ValueType.STRING"`
988
+ const enumFixed = rawYaml.replace(/!enum (\S+)/g, '!enum "$1"');
989
+
990
+ // Patch long-line wrapping to match pyyaml style
991
+ return patchYamlToPyyaml(enumFixed);
992
+ }
993
+
994
+ /** Wrapper class for !enum YAML tag */
995
+ class V2EnumValue {
996
+ constructor(public value: string) {}
997
+ }
998
+
999
+ /**
1000
+ * Factory function for creating V2ProjectSyncStrategy
1001
+ */
1002
+ export function createV2ProjectSyncStrategy(
1003
+ apiClientFactory: ApiClientFactory,
1004
+ logger: ILogger
1005
+ ): V2ProjectSyncStrategy {
1006
+ return new V2ProjectSyncStrategy(apiClientFactory, logger);
1007
+ }