newo 1.2.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/.env.example ADDED
@@ -0,0 +1,18 @@
1
+ # NEWO endpoints
2
+ NEWO_BASE_URL=https://app.newo.ai
3
+
4
+ # Project you want to sync
5
+ NEWO_PROJECT_ID=b78188ba-0df0-46a8-8713-f0d7cff0a06e
6
+
7
+ # Auth (choose one)
8
+ # 1) Recommended: API key that can be exchanged for tokens:
9
+ NEWO_API_KEY=put_api_key_here
10
+
11
+ # 2) Optional bootstrap tokens (used if present)
12
+ NEWO_ACCESS_TOKEN=
13
+ NEWO_REFRESH_TOKEN=
14
+
15
+ # Optional: explicit refresh endpoint if your server supports it (else leave blank)
16
+ NEWO_REFRESH_URL=
17
+ # Example:
18
+ # NEWO_REFRESH_URL=https://app.newo.ai/api/v1/auth/token/refresh
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # NEWO CLI
2
+
3
+ Mirror NEWO "Project → Agent → Flow → Skills" to local files and back, Git-first.
4
+
5
+ ## Install
6
+ ```bash
7
+ npm install
8
+ ```
9
+
10
+ ## Configure
11
+ ```bash
12
+ cp .env.example .env
13
+ # Edit .env with your values
14
+ ```
15
+ Required:
16
+ - `NEWO_BASE_URL` (default `https://app.newo.ai`)
17
+ - `NEWO_PROJECT_ID`
18
+ - One of:
19
+ - `NEWO_API_KEY` to exchange for tokens (recommended), or
20
+ - `NEWO_ACCESS_TOKEN` (+ optional `NEWO_REFRESH_TOKEN` and `NEWO_REFRESH_URL`)
21
+
22
+ ## Commands
23
+ ```bash
24
+ npx newo pull # download project -> ./project
25
+ npx newo status # list modified files
26
+ npx newo push # upload modified *.guidance/*.jinja back to NEWO
27
+ ```
28
+
29
+ Files are stored as:
30
+ - `./project/<AgentIdn>/<FlowIdn>/<SkillIdn>.guidance` (AI guidance scripts)
31
+ - `./project/<AgentIdn>/<FlowIdn>/<SkillIdn>.jinja` (NSL/Jinja template scripts)
32
+
33
+ Hashes are tracked in `.newo/hashes.json` so only changed files are pushed.
34
+ Project structure is also exported to `flows.yaml` for reference.
35
+
36
+ ## Features
37
+ - **Two-way sync**: Pull NEWO projects to local files, push local changes back
38
+ - **Change detection**: SHA256 hashing prevents unnecessary uploads
39
+ - **Multiple file types**: `.guidance` (AI prompts) and `.jinja` (NSL templates)
40
+ - **Project structure export**: Generates `flows.yaml` with complete project metadata
41
+ - **Robust authentication**: API key exchange with automatic token refresh
42
+ - **CI/CD ready**: GitHub Actions workflow included
43
+
44
+ ## CI/CD (GitHub Actions)
45
+ Create `.github/workflows/deploy.yml`:
46
+ ```yaml
47
+ name: Deploy NEWO Skills
48
+ on:
49
+ push:
50
+ branches: [ main ]
51
+ paths:
52
+ - 'project/**/*.guidance'
53
+ - 'project/**/*.jinja'
54
+ jobs:
55
+ deploy:
56
+ runs-on: ubuntu-latest
57
+ steps:
58
+ - uses: actions/checkout@v4
59
+ - uses: actions/setup-node@v4
60
+ with:
61
+ node-version: 20
62
+ - run: npm ci
63
+ - run: node ./src/cli.js push
64
+ env:
65
+ NEWO_BASE_URL: https://app.newo.ai
66
+ NEWO_PROJECT_ID: b78188ba-0df0-46a8-8713-f0d7cff0a06e
67
+ NEWO_API_KEY: ${{ secrets.NEWO_API_KEY }}
68
+ # Optional:
69
+ # NEWO_REFRESH_URL: ${{ secrets.NEWO_REFRESH_URL }}
70
+ ```
71
+
72
+ ## API Endpoints
73
+ - `GET /api/v1/bff/agents/list?project_id=...` - List project agents
74
+ - `GET /api/v1/designer/flows/{flowId}/skills` - List skills in flow
75
+ - `GET /api/v1/designer/skills/{skillId}` - Get skill content
76
+ - `PUT /api/v1/designer/flows/skills/{skillId}` - Update skill content
77
+ - `GET /api/v1/designer/flows/{flowId}/events` - List flow events (for flows.yaml)
78
+ - `GET /api/v1/designer/flows/{flowId}/states` - List flow states (for flows.yaml)
79
+ - `POST /api/v1/auth/api-key/token` - Exchange API key for access tokens
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "newo",
3
+ "version": "1.2.0",
4
+ "description": "NEWO CLI: sync flows/skills between NEWO and local files",
5
+ "type": "module",
6
+ "bin": {
7
+ "newo": "./src/cli.js"
8
+ },
9
+ "files": [
10
+ "src/**/*.js",
11
+ "README.md",
12
+ ".env.example"
13
+ ],
14
+ "keywords": [
15
+ "newo",
16
+ "cli",
17
+ "ai",
18
+ "agent",
19
+ "automation",
20
+ "sync",
21
+ "local-development"
22
+ ],
23
+ "author": "Your Name",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/yourusername/newo-cli.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/yourusername/newo-cli/issues"
31
+ },
32
+ "homepage": "https://github.com/yourusername/newo-cli#readme",
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "dependencies": {
37
+ "axios": "^1.7.7",
38
+ "dotenv": "^16.4.5",
39
+ "fs-extra": "^11.2.0",
40
+ "js-yaml": "^4.1.0",
41
+ "minimist": "^1.2.8"
42
+ },
43
+ "scripts": {
44
+ "dev": "node ./src/cli.js",
45
+ "pull": "node ./src/cli.js pull",
46
+ "push": "node ./src/cli.js push",
47
+ "status": "node ./src/cli.js status"
48
+ }
49
+ }
package/src/api.js ADDED
@@ -0,0 +1,93 @@
1
+ import axios from 'axios';
2
+ import dotenv from 'dotenv';
3
+ import { getValidAccessToken, forceReauth } from './auth.js';
4
+ dotenv.config();
5
+
6
+ const { NEWO_BASE_URL } = process.env;
7
+
8
+ export async function makeClient(verbose = false) {
9
+ let accessToken = await getValidAccessToken();
10
+ if (verbose) console.log('✓ Access token obtained');
11
+
12
+ const client = axios.create({
13
+ baseURL: NEWO_BASE_URL,
14
+ headers: { accept: 'application/json' }
15
+ });
16
+
17
+ client.interceptors.request.use(async (config) => {
18
+ config.headers = config.headers || {};
19
+ config.headers.Authorization = `Bearer ${accessToken}`;
20
+ if (verbose) {
21
+ console.log(`→ ${config.method?.toUpperCase()} ${config.url}`);
22
+ if (config.data) console.log(' Data:', JSON.stringify(config.data, null, 2));
23
+ if (config.params) console.log(' Params:', config.params);
24
+ }
25
+ return config;
26
+ });
27
+
28
+ let retried = false;
29
+ client.interceptors.response.use(
30
+ r => {
31
+ if (verbose) {
32
+ console.log(`← ${r.status} ${r.config.method?.toUpperCase()} ${r.config.url}`);
33
+ if (r.data && Object.keys(r.data).length < 20) {
34
+ console.log(' Response:', JSON.stringify(r.data, null, 2));
35
+ } else if (r.data) {
36
+ console.log(` Response: [${typeof r.data}] ${Array.isArray(r.data) ? r.data.length + ' items' : 'large object'}`);
37
+ }
38
+ }
39
+ return r;
40
+ },
41
+ async (error) => {
42
+ const status = error?.response?.status;
43
+ if (verbose) {
44
+ console.log(`← ${status} ${error.config?.method?.toUpperCase()} ${error.config?.url} - ${error.message}`);
45
+ if (error.response?.data) console.log(' Error data:', error.response.data);
46
+ }
47
+ if (status === 401 && !retried) {
48
+ retried = true;
49
+ if (verbose) console.log('🔄 Retrying with fresh token...');
50
+ accessToken = await forceReauth();
51
+ error.config.headers.Authorization = `Bearer ${accessToken}`;
52
+ return client.request(error.config);
53
+ }
54
+ throw error;
55
+ }
56
+ );
57
+
58
+ return client;
59
+ }
60
+
61
+ export async function listAgents(client, projectId) {
62
+ const r = await client.get(`/api/v1/bff/agents/list`, { params: { project_id: projectId } });
63
+ return r.data;
64
+ }
65
+
66
+ export async function getProjectMeta(client, projectId) {
67
+ const r = await client.get(`/api/v1/designer/projects/by-id/${projectId}`);
68
+ return r.data;
69
+ }
70
+
71
+ export async function listFlowSkills(client, flowId) {
72
+ const r = await client.get(`/api/v1/designer/flows/${flowId}/skills`);
73
+ return r.data;
74
+ }
75
+
76
+ export async function getSkill(client, skillId) {
77
+ const r = await client.get(`/api/v1/designer/skills/${skillId}`);
78
+ return r.data;
79
+ }
80
+
81
+ export async function updateSkill(client, skillObject) {
82
+ await client.put(`/api/v1/designer/flows/skills/${skillObject.id}`, skillObject);
83
+ }
84
+
85
+ export async function listFlowEvents(client, flowId) {
86
+ const r = await client.get(`/api/v1/designer/flows/${flowId}/events`);
87
+ return r.data;
88
+ }
89
+
90
+ export async function listFlowStates(client, flowId) {
91
+ const r = await client.get(`/api/v1/designer/flows/${flowId}/states`);
92
+ return r.data;
93
+ }
package/src/auth.js ADDED
@@ -0,0 +1,92 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import axios from 'axios';
4
+ import dotenv from 'dotenv';
5
+ dotenv.config();
6
+
7
+ const {
8
+ NEWO_BASE_URL,
9
+ NEWO_API_KEY,
10
+ NEWO_ACCESS_TOKEN,
11
+ NEWO_REFRESH_TOKEN,
12
+ NEWO_REFRESH_URL
13
+ } = process.env;
14
+
15
+ const STATE_DIR = path.join(process.cwd(), '.newo');
16
+ const TOKENS_PATH = path.join(STATE_DIR, 'tokens.json');
17
+
18
+ async function saveTokens(tokens) {
19
+ await fs.ensureDir(STATE_DIR);
20
+ await fs.writeJson(TOKENS_PATH, tokens, { spaces: 2 });
21
+ }
22
+
23
+ async function loadTokens() {
24
+ if (await fs.pathExists(TOKENS_PATH)) {
25
+ return fs.readJson(TOKENS_PATH);
26
+ }
27
+ if (NEWO_ACCESS_TOKEN || NEWO_REFRESH_TOKEN) {
28
+ const t = {
29
+ access_token: NEWO_ACCESS_TOKEN || '',
30
+ refresh_token: NEWO_REFRESH_TOKEN || '',
31
+ expires_at: Date.now() + 10 * 60 * 1000
32
+ };
33
+ await saveTokens(t);
34
+ return t;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ function isExpired(tokens) {
40
+ if (!tokens?.expires_at) return false;
41
+ return Date.now() >= tokens.expires_at - 10_000;
42
+ }
43
+
44
+ export async function exchangeApiKeyForToken() {
45
+ if (!NEWO_API_KEY) throw new Error('NEWO_API_KEY not set. Provide an API key in .env');
46
+ const url = `${NEWO_BASE_URL}/api/v1/auth/api-key/token`;
47
+ const res = await axios.post(url, {}, { headers: { 'x-api-key': NEWO_API_KEY, 'accept': 'application/json' } });
48
+ const data = res.data || {};
49
+ const access = data.access_token || data.token || data.accessToken;
50
+ const refresh = data.refresh_token || data.refreshToken || '';
51
+ const expiresInSec = data.expires_in || data.expiresIn || 3600;
52
+ const tokens = { access_token: access, refresh_token: refresh, expires_at: Date.now() + expiresInSec * 1000 };
53
+ await saveTokens(tokens);
54
+ return tokens;
55
+ }
56
+
57
+ export async function refreshWithEndpoint(refreshToken) {
58
+ if (!NEWO_REFRESH_URL) throw new Error('NEWO_REFRESH_URL not set');
59
+ const res = await axios.post(NEWO_REFRESH_URL, { refresh_token: refreshToken }, { headers: { 'accept': 'application/json' } });
60
+ const data = res.data || {};
61
+ const access = data.access_token || data.token || data.accessToken;
62
+ const refresh = data.refresh_token ?? refreshToken;
63
+ const expiresInSec = data.expires_in || 3600;
64
+ const tokens = { access_token: access, refresh_token: refresh, expires_at: Date.now() + expiresInSec * 1000 };
65
+ await saveTokens(tokens);
66
+ return tokens;
67
+ }
68
+
69
+ export async function getValidAccessToken() {
70
+ let tokens = await loadTokens();
71
+ if (!tokens || !tokens.access_token) {
72
+ tokens = await exchangeApiKeyForToken();
73
+ return tokens.access_token;
74
+ }
75
+ if (!isExpired(tokens)) return tokens.access_token;
76
+
77
+ if (NEWO_REFRESH_URL && tokens.refresh_token) {
78
+ try {
79
+ tokens = await refreshWithEndpoint(tokens.refresh_token);
80
+ return tokens.access_token;
81
+ } catch (e) {
82
+ console.warn('Refresh failed, falling back to API key exchange…');
83
+ }
84
+ }
85
+ tokens = await exchangeApiKeyForToken();
86
+ return tokens.access_token;
87
+ }
88
+
89
+ export async function forceReauth() {
90
+ const tokens = await exchangeApiKeyForToken();
91
+ return tokens.access_token;
92
+ }
package/src/cli.js ADDED
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ import minimist from 'minimist';
3
+ import dotenv from 'dotenv';
4
+ import { makeClient, getProjectMeta } from './api.js';
5
+ import { pullAll, pushChanged, status } from './sync.js';
6
+
7
+ dotenv.config();
8
+ const { NEWO_PROJECT_ID } = process.env;
9
+
10
+ async function main() {
11
+ const args = minimist(process.argv.slice(2));
12
+ const cmd = args._[0];
13
+ const verbose = args.verbose || args.v;
14
+
15
+ if (!cmd || ['help', '-h', '--help'].includes(cmd)) {
16
+ console.log(`NEWO CLI
17
+ Usage:
18
+ newo pull # download project -> ./project
19
+ newo push # upload modified *.guidance/*.jinja back to NEWO
20
+ newo status # show modified files
21
+ newo meta # get project metadata (debug)
22
+
23
+ Flags:
24
+ --verbose, -v # enable detailed logging
25
+
26
+ Env:
27
+ NEWO_BASE_URL, NEWO_PROJECT_ID, NEWO_API_KEY, NEWO_REFRESH_URL (optional)
28
+ `);
29
+ return;
30
+ }
31
+
32
+ const client = await makeClient(verbose);
33
+
34
+ if (cmd === 'pull') {
35
+ if (!NEWO_PROJECT_ID) throw new Error('NEWO_PROJECT_ID is not set in env');
36
+ await pullAll(client, NEWO_PROJECT_ID, verbose);
37
+ } else if (cmd === 'push') {
38
+ await pushChanged(client, verbose);
39
+ } else if (cmd === 'status') {
40
+ await status(verbose);
41
+ } else if (cmd === 'meta') {
42
+ if (!NEWO_PROJECT_ID) throw new Error('NEWO_PROJECT_ID is not set in env');
43
+ const meta = await getProjectMeta(client, NEWO_PROJECT_ID);
44
+ console.log(JSON.stringify(meta, null, 2));
45
+ } else {
46
+ console.error('Unknown command:', cmd);
47
+ process.exit(1);
48
+ }
49
+ }
50
+
51
+ main().catch((e) => {
52
+ console.error(e?.response?.data || e);
53
+ process.exit(1);
54
+ });
package/src/fsutil.js ADDED
@@ -0,0 +1,26 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+
4
+ export const ROOT_DIR = path.join(process.cwd(), 'project');
5
+ export const STATE_DIR = path.join(process.cwd(), '.newo');
6
+ export const MAP_PATH = path.join(STATE_DIR, 'map.json');
7
+ export const HASHES_PATH = path.join(STATE_DIR, 'hashes.json');
8
+
9
+ export async function ensureState() {
10
+ await fs.ensureDir(STATE_DIR);
11
+ await fs.ensureDir(ROOT_DIR);
12
+ }
13
+
14
+ export function skillPath(agentIdn, flowIdn, skillIdn, runnerType = 'guidance') {
15
+ const extension = runnerType === 'nsl' ? '.jinja' : '.guidance';
16
+ return path.join(ROOT_DIR, agentIdn, flowIdn, `${skillIdn}${extension}`);
17
+ }
18
+
19
+ export async function writeFileAtomic(filepath, content) {
20
+ await fs.ensureDir(path.dirname(filepath));
21
+ await fs.writeFile(filepath, content, 'utf8');
22
+ }
23
+
24
+ export async function readIfExists(filepath) {
25
+ return (await fs.pathExists(filepath)) ? fs.readFile(filepath, 'utf8') : null;
26
+ }
package/src/hash.js ADDED
@@ -0,0 +1,17 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs-extra';
3
+ import { ensureState, HASHES_PATH } from './fsutil.js';
4
+
5
+ export function sha256(str) {
6
+ return crypto.createHash('sha256').update(str, 'utf8').digest('hex');
7
+ }
8
+
9
+ export async function loadHashes() {
10
+ await ensureState();
11
+ if (await fs.pathExists(HASHES_PATH)) return fs.readJson(HASHES_PATH);
12
+ return {};
13
+ }
14
+
15
+ export async function saveHashes(h) {
16
+ await fs.writeJson(HASHES_PATH, h, { spaces: 2 });
17
+ }
package/src/sync.js ADDED
@@ -0,0 +1,284 @@
1
+ import { listAgents, listFlowSkills, updateSkill, listFlowEvents, listFlowStates } from './api.js';
2
+ import { ensureState, skillPath, writeFileAtomic, readIfExists, MAP_PATH } from './fsutil.js';
3
+ import fs from 'fs-extra';
4
+ import { sha256, loadHashes, saveHashes } from './hash.js';
5
+ import yaml from 'js-yaml';
6
+ import path from 'path';
7
+
8
+ export async function pullAll(client, projectId, verbose = false) {
9
+ await ensureState();
10
+ if (verbose) console.log(`🔍 Fetching agents for project ${projectId}...`);
11
+ const agents = await listAgents(client, projectId);
12
+ if (verbose) console.log(`📦 Found ${agents.length} agents`);
13
+
14
+ const idMap = { projectId, agents: {} };
15
+
16
+ for (const agent of agents) {
17
+ const aKey = agent.idn;
18
+ idMap.agents[aKey] = { id: agent.id, flows: {} };
19
+
20
+ for (const flow of agent.flows ?? []) {
21
+ idMap.agents[aKey].flows[flow.idn] = { id: flow.id, skills: {} };
22
+
23
+ const skills = await listFlowSkills(client, flow.id);
24
+ for (const s of skills) {
25
+ const file = skillPath(agent.idn, flow.idn, s.idn, s.runner_type);
26
+ await writeFileAtomic(file, s.prompt_script || '');
27
+ // Store complete skill metadata for push operations
28
+ idMap.agents[aKey].flows[flow.idn].skills[s.idn] = {
29
+ id: s.id,
30
+ title: s.title,
31
+ idn: s.idn,
32
+ runner_type: s.runner_type,
33
+ model: s.model,
34
+ parameters: s.parameters,
35
+ path: s.path
36
+ };
37
+ console.log(`✓ Pulled ${file}`);
38
+ }
39
+ }
40
+ }
41
+
42
+ await fs.writeJson(MAP_PATH, idMap, { spaces: 2 });
43
+
44
+ // Generate flows.yaml
45
+ if (verbose) console.log('📄 Generating flows.yaml...');
46
+ await generateFlowsYaml(client, agents, verbose);
47
+
48
+ const hashes = {};
49
+ for (const [agentIdn, agentObj] of Object.entries(idMap.agents)) {
50
+ for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
51
+ for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
52
+ const p = skillPath(agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
53
+ const content = await fs.readFile(p, 'utf8');
54
+ hashes[p] = sha256(content);
55
+ }
56
+ }
57
+ }
58
+ await saveHashes(hashes);
59
+ }
60
+
61
+ export async function pushChanged(client, verbose = false) {
62
+ await ensureState();
63
+ if (!(await fs.pathExists(MAP_PATH))) {
64
+ throw new Error('Missing .newo/map.json. Run `newo pull` first.');
65
+ }
66
+
67
+ if (verbose) console.log('📋 Loading project mapping...');
68
+ const idMap = await fs.readJson(MAP_PATH);
69
+ if (verbose) console.log('🔍 Loading file hashes...');
70
+ const oldHashes = await loadHashes();
71
+ const newHashes = { ...oldHashes };
72
+
73
+ if (verbose) console.log('🔄 Scanning for changes...');
74
+ let pushed = 0;
75
+ let scanned = 0;
76
+
77
+ for (const [agentIdn, agentObj] of Object.entries(idMap.agents)) {
78
+ if (verbose) console.log(` 📁 Scanning agent: ${agentIdn}`);
79
+ for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
80
+ if (verbose) console.log(` 📁 Scanning flow: ${flowIdn}`);
81
+ for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
82
+ const p = skillPath(agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
83
+ scanned++;
84
+ if (verbose) console.log(` 📄 Checking: ${p}`);
85
+
86
+ const content = await readIfExists(p);
87
+ if (content === null) {
88
+ if (verbose) console.log(` ⚠️ File not found: ${p}`);
89
+ continue;
90
+ }
91
+
92
+ const h = sha256(content);
93
+ const oldHash = oldHashes[p];
94
+ if (verbose) {
95
+ console.log(` 🔍 Hash comparison:`);
96
+ console.log(` Old: ${oldHash || 'none'}`);
97
+ console.log(` New: ${h}`);
98
+ }
99
+
100
+ if (oldHash !== h) {
101
+ if (verbose) console.log(` 🔄 File changed, preparing to push...`);
102
+
103
+ // Create complete skill object with updated prompt_script
104
+ const skillObject = {
105
+ id: skillMeta.id,
106
+ title: skillMeta.title,
107
+ idn: skillMeta.idn,
108
+ prompt_script: content,
109
+ runner_type: skillMeta.runner_type,
110
+ model: skillMeta.model,
111
+ parameters: skillMeta.parameters,
112
+ path: skillMeta.path
113
+ };
114
+
115
+ if (verbose) {
116
+ console.log(` 📤 Pushing skill object:`);
117
+ console.log(` ID: ${skillObject.id}`);
118
+ console.log(` Title: ${skillObject.title}`);
119
+ console.log(` IDN: ${skillObject.idn}`);
120
+ console.log(` Content length: ${content.length} chars`);
121
+ console.log(` Content preview: ${content.substring(0, 100).replace(/\n/g, '\\n')}...`);
122
+ }
123
+
124
+ await updateSkill(client, skillObject);
125
+ console.log(`↑ Pushed ${p}`);
126
+ newHashes[p] = h;
127
+ pushed++;
128
+ } else if (verbose) {
129
+ console.log(` ✓ No changes`);
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ if (verbose) console.log(`🔄 Scanned ${scanned} files, found ${pushed} changes`);
136
+ await saveHashes(newHashes);
137
+ console.log(pushed ? `✅ Push complete. ${pushed} file(s) updated.` : '✅ Nothing to push.');
138
+ }
139
+
140
+ export async function status(verbose = false) {
141
+ await ensureState();
142
+ if (!(await fs.pathExists(MAP_PATH))) {
143
+ console.log('No map. Run `newo pull` first.');
144
+ return;
145
+ }
146
+
147
+ if (verbose) console.log('📋 Loading project mapping and hashes...');
148
+ const idMap = await fs.readJson(MAP_PATH);
149
+ const hashes = await loadHashes();
150
+ let dirty = 0;
151
+
152
+ for (const [agentIdn, agentObj] of Object.entries(idMap.agents)) {
153
+ if (verbose) console.log(` 📁 Checking agent: ${agentIdn}`);
154
+ for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
155
+ if (verbose) console.log(` 📁 Checking flow: ${flowIdn}`);
156
+ for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
157
+ const p = skillPath(agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
158
+ const exists = await fs.pathExists(p);
159
+ if (!exists) {
160
+ console.log(`D ${p}`);
161
+ dirty++;
162
+ if (verbose) console.log(` ❌ Deleted: ${p}`);
163
+ continue;
164
+ }
165
+ const content = await fs.readFile(p, 'utf8');
166
+ const h = sha256(content);
167
+ const oldHash = hashes[p];
168
+ if (verbose) {
169
+ console.log(` 📄 ${p}`);
170
+ console.log(` Old hash: ${oldHash || 'none'}`);
171
+ console.log(` New hash: ${h}`);
172
+ }
173
+ if (oldHash !== h) {
174
+ console.log(`M ${p}`);
175
+ dirty++;
176
+ if (verbose) console.log(` 🔄 Modified: ${p}`);
177
+ } else if (verbose) {
178
+ console.log(` ✓ Unchanged: ${p}`);
179
+ }
180
+ }
181
+ }
182
+ }
183
+ console.log(dirty ? `${dirty} changed file(s).` : 'Clean.');
184
+ }
185
+
186
+ async function generateFlowsYaml(client, agents, verbose = false) {
187
+ const flowsData = { flows: [] };
188
+
189
+ for (const agent of agents) {
190
+ if (verbose) console.log(` 📁 Processing agent: ${agent.idn}`);
191
+
192
+ const agentFlows = [];
193
+
194
+ for (const flow of agent.flows ?? []) {
195
+ if (verbose) console.log(` 📄 Processing flow: ${flow.idn}`);
196
+
197
+ // Get skills for this flow
198
+ const skills = await listFlowSkills(client, flow.id);
199
+ const skillsData = skills.map(skill => ({
200
+ idn: skill.idn,
201
+ title: skill.title || "",
202
+ prompt_script: `flows/${flow.idn}/${skill.idn}.${skill.runner_type === 'nsl' ? 'jinja' : 'nsl'}`,
203
+ runner_type: `!enum "RunnerType.${skill.runner_type}"`,
204
+ model: {
205
+ model_idn: skill.model.model_idn,
206
+ provider_idn: skill.model.provider_idn
207
+ },
208
+ parameters: skill.parameters.map(param => ({
209
+ name: param.name,
210
+ default_value: param.default_value || " "
211
+ }))
212
+ }));
213
+
214
+ // Get events for this flow
215
+ let eventsData = [];
216
+ try {
217
+ const events = await listFlowEvents(client, flow.id);
218
+ eventsData = events.map(event => ({
219
+ title: event.description,
220
+ idn: event.idn,
221
+ skill_selector: `!enum "SkillSelector.${event.skill_selector}"`,
222
+ skill_idn: event.skill_idn,
223
+ state_idn: event.state_idn,
224
+ integration_idn: event.integration_idn,
225
+ connector_idn: event.connector_idn,
226
+ interrupt_mode: `!enum "InterruptMode.${event.interrupt_mode}"`
227
+ }));
228
+ if (verbose) console.log(` 📋 Found ${events.length} events`);
229
+ } catch (error) {
230
+ if (verbose) console.log(` ⚠️ No events found for flow ${flow.idn}`);
231
+ }
232
+
233
+ // Get state fields for this flow
234
+ let stateFieldsData = [];
235
+ try {
236
+ const states = await listFlowStates(client, flow.id);
237
+ stateFieldsData = states.map(state => ({
238
+ title: state.title,
239
+ idn: state.idn,
240
+ default_value: state.default_value,
241
+ scope: `!enum "StateFieldScope.${state.scope}"`
242
+ }));
243
+ if (verbose) console.log(` 📊 Found ${states.length} state fields`);
244
+ } catch (error) {
245
+ if (verbose) console.log(` ⚠️ No state fields found for flow ${flow.idn}`);
246
+ }
247
+
248
+ agentFlows.push({
249
+ idn: flow.idn,
250
+ title: flow.title,
251
+ description: flow.description || null,
252
+ default_runner_type: `!enum "RunnerType.${flow.default_runner_type}"`,
253
+ default_provider_idn: flow.default_model.provider_idn,
254
+ default_model_idn: flow.default_model.model_idn,
255
+ skills: skillsData,
256
+ events: eventsData,
257
+ state_fields: stateFieldsData
258
+ });
259
+ }
260
+
261
+ flowsData.flows.push({
262
+ agent_idn: agent.idn,
263
+ agent_description: agent.description,
264
+ agent_flows: agentFlows
265
+ });
266
+ }
267
+
268
+ // Convert to YAML and write to file with custom enum handling
269
+ let yamlContent = yaml.dump(flowsData, {
270
+ indent: 2,
271
+ lineWidth: -1,
272
+ noRefs: true,
273
+ sortKeys: false,
274
+ quotingType: '"',
275
+ forceQuotes: false
276
+ });
277
+
278
+ // Post-process to fix enum formatting
279
+ yamlContent = yamlContent.replace(/"(!enum "[^"]+")"/g, '$1');
280
+
281
+ const yamlPath = path.join('flows.yaml');
282
+ await fs.writeFile(yamlPath, yamlContent, 'utf8');
283
+ console.log(`✓ Generated flows.yaml`);
284
+ }