@xano/cli 1.0.4 → 1.0.6-beta.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/README.md +139 -4
- package/dist/base-command.d.ts +27 -0
- package/dist/base-command.js +124 -1
- package/dist/commands/auth/index.d.ts +45 -1
- package/dist/commands/auth/index.js +197 -29
- package/dist/commands/ephemeral/create/index.d.ts +18 -0
- package/dist/commands/ephemeral/create/index.js +103 -0
- package/dist/commands/ephemeral/delete/index.d.ts +18 -0
- package/dist/commands/ephemeral/delete/index.js +99 -0
- package/dist/commands/ephemeral/edit/index.d.ts +18 -0
- package/dist/commands/ephemeral/edit/index.js +109 -0
- package/dist/commands/ephemeral/get/index.d.ts +21 -0
- package/dist/commands/ephemeral/get/index.js +125 -0
- package/dist/commands/ephemeral/impersonate/index.d.ts +19 -0
- package/dist/commands/ephemeral/impersonate/index.js +112 -0
- package/dist/commands/ephemeral/list/index.d.ts +19 -0
- package/dist/commands/ephemeral/list/index.js +122 -0
- package/dist/commands/ephemeral/pull/index.d.ts +23 -0
- package/dist/commands/ephemeral/pull/index.js +248 -0
- package/dist/commands/{knowledge → ephemeral}/push/index.d.ts +8 -5
- package/dist/commands/ephemeral/push/index.js +143 -0
- package/dist/commands/function/run/index.d.ts +40 -0
- package/dist/commands/function/run/index.js +244 -0
- package/dist/commands/knowledge/{pull → get}/index.d.ts +6 -2
- package/dist/commands/knowledge/get/index.js +126 -0
- package/dist/commands/knowledge/list/index.d.ts +18 -0
- package/dist/commands/knowledge/list/index.js +135 -0
- package/dist/commands/sandbox/pull/index.js +12 -2
- package/dist/commands/sandbox/push/index.d.ts +1 -1
- package/dist/commands/sandbox/push/index.js +19 -14
- package/dist/commands/sandbox/reset/index.js +12 -0
- package/dist/commands/static_host/build/create/index.d.ts +9 -1
- package/dist/commands/static_host/build/create/index.js +54 -4
- package/dist/commands/static_host/build/delete/index.d.ts +19 -0
- package/dist/commands/static_host/build/delete/index.js +114 -0
- package/dist/commands/static_host/build/get/index.d.ts +1 -1
- package/dist/commands/static_host/build/get/index.js +16 -10
- package/dist/commands/static_host/build/pull/index.d.ts +52 -0
- package/dist/commands/static_host/build/pull/index.js +300 -0
- package/dist/commands/static_host/build/push/index.d.ts +23 -0
- package/dist/commands/static_host/build/push/index.js +228 -0
- package/dist/commands/static_host/create/index.d.ts +17 -0
- package/dist/commands/static_host/create/index.js +86 -0
- package/dist/commands/static_host/deploy/index.d.ts +18 -0
- package/dist/commands/static_host/deploy/index.js +105 -0
- package/dist/commands/static_host/edit/index.d.ts +23 -0
- package/dist/commands/static_host/edit/index.js +151 -0
- package/dist/commands/static_host/get/index.d.ts +18 -0
- package/dist/commands/static_host/get/index.js +94 -0
- package/dist/commands/static_host/migrate/index.d.ts +44 -0
- package/dist/commands/static_host/migrate/index.js +205 -0
- package/dist/commands/tenant/snapshot/create/index.d.ts +17 -0
- package/dist/commands/tenant/snapshot/create/index.js +78 -0
- package/dist/commands/tenant/snapshot/delete/index.d.ts +19 -0
- package/dist/commands/tenant/snapshot/delete/index.js +102 -0
- package/dist/commands/tenant/snapshot/list/index.d.ts +16 -0
- package/dist/commands/tenant/snapshot/list/index.js +96 -0
- package/dist/commands/tenant/snapshot/swap/index.d.ts +19 -0
- package/dist/commands/tenant/snapshot/swap/index.js +103 -0
- package/dist/commands/tenant/unit_test/run/index.d.ts +1 -0
- package/dist/commands/tenant/unit_test/run/index.js +7 -0
- package/dist/commands/tenant/unit_test/run_all/index.js +7 -3
- package/dist/commands/unit_test/run/index.d.ts +1 -0
- package/dist/commands/unit_test/run/index.js +8 -0
- package/dist/commands/unit_test/run_all/index.js +7 -3
- package/dist/commands/workspace/pull/index.js +13 -2
- package/dist/commands/workspace/push/index.d.ts +1 -1
- package/dist/commands/workspace/push/index.js +15 -5
- package/dist/utils/function-input.d.ts +77 -0
- package/dist/utils/function-input.js +148 -0
- package/dist/utils/knowledge-sync.d.ts +84 -79
- package/dist/utils/knowledge-sync.js +376 -323
- package/dist/utils/multidoc-push.d.ts +30 -18
- package/dist/utils/multidoc-push.js +284 -148
- package/dist/utils/reference-checker.js +2 -2
- package/dist/utils/static-host-files.d.ts +21 -0
- package/dist/utils/static-host-files.js +41 -0
- package/oclif.manifest.json +5046 -2691
- package/package.json +10 -1
- package/dist/commands/knowledge/pull/index.js +0 -86
- package/dist/commands/knowledge/push/index.js +0 -126
|
@@ -1,380 +1,433 @@
|
|
|
1
|
-
|
|
1
|
+
/* eslint-disable camelcase -- knowledge_type / dry_run / guid_map are external Metadata API field names */
|
|
2
|
+
import * as yaml from 'js-yaml';
|
|
3
|
+
import snakeCase from 'lodash.snakecase';
|
|
4
|
+
import { minimatch } from 'minimatch';
|
|
2
5
|
import * as fs from 'node:fs';
|
|
3
|
-
import { join, relative,
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
7
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// Knowledge mirrors the multidoc model: the server returns self-describing
|
|
10
|
+
// *objects* and the CLI owns the on-disk folder layout + YAML frontmatter
|
|
11
|
+
// (see workspace/pull for the equivalent `.xs` mapping). We deliberately do NOT
|
|
12
|
+
// let the server dictate file paths.
|
|
13
|
+
export const KNOWLEDGE_DIR = 'knowledge';
|
|
14
|
+
// ── Frontmatter ──────────────────────────────────────────────────────────────
|
|
15
|
+
const FRONTMATTER_ORDER = ['name', 'description', 'knowledge_type', 'scope', 'inclusion', 'enabled', 'guid'];
|
|
9
16
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* Returns null when valid, otherwise a human-readable reason.
|
|
17
|
+
* Build a primary `.md` file body: YAML frontmatter (built from the object's
|
|
18
|
+
* structured fields) followed by the markdown content.
|
|
13
19
|
*/
|
|
14
|
-
export function
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
export function buildPrimaryContent(obj) {
|
|
21
|
+
const meta = {};
|
|
22
|
+
meta.name = obj.name;
|
|
23
|
+
if (obj.description !== undefined)
|
|
24
|
+
meta.description = obj.description;
|
|
25
|
+
meta.knowledge_type = obj.knowledge_type;
|
|
26
|
+
if (obj.scope !== undefined)
|
|
27
|
+
meta.scope = obj.scope;
|
|
28
|
+
if (obj.mode !== undefined)
|
|
29
|
+
meta.inclusion = modeToInclusion(obj.mode);
|
|
30
|
+
if (obj.enabled !== undefined)
|
|
31
|
+
meta.enabled = obj.enabled;
|
|
32
|
+
if (obj.guid !== undefined)
|
|
33
|
+
meta.guid = obj.guid;
|
|
34
|
+
// Emit keys in a stable, human-friendly order.
|
|
35
|
+
const ordered = {};
|
|
36
|
+
for (const key of FRONTMATTER_ORDER) {
|
|
37
|
+
if (key in meta)
|
|
38
|
+
ordered[key] = meta[key];
|
|
39
|
+
}
|
|
40
|
+
const fm = yaml.dump(ordered, { lineWidth: -1 }).trimEnd();
|
|
41
|
+
const body = obj.content ?? '';
|
|
42
|
+
return `---\n${fm}\n---\n${body}`;
|
|
25
43
|
}
|
|
26
44
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* Returns null when the name is unsafe.
|
|
45
|
+
* Split a primary `.md` file into its frontmatter fields and markdown body.
|
|
46
|
+
* Returns `{meta: {}, body: <raw>}` when there is no leading `---` block.
|
|
30
47
|
*/
|
|
31
|
-
export function
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
return
|
|
48
|
+
export function parsePrimaryContent(raw) {
|
|
49
|
+
const lines = raw.split('\n');
|
|
50
|
+
if (lines[0]?.trim() !== '---')
|
|
51
|
+
return { body: raw, meta: {} };
|
|
52
|
+
let close = -1;
|
|
53
|
+
for (let i = 1; i < lines.length; i++) {
|
|
54
|
+
if (lines[i].trim() === '---') {
|
|
55
|
+
close = i;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
35
58
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
59
|
+
if (close === -1)
|
|
60
|
+
return { body: raw, meta: {} };
|
|
61
|
+
const fmText = lines.slice(1, close).join('\n');
|
|
62
|
+
const body = lines.slice(close + 1).join('\n');
|
|
63
|
+
let meta = {};
|
|
64
|
+
try {
|
|
65
|
+
meta = yaml.load(fmText) ?? {};
|
|
39
66
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (target !== base && !target.startsWith(base + sep)) {
|
|
43
|
-
return null;
|
|
67
|
+
catch {
|
|
68
|
+
meta = {};
|
|
44
69
|
}
|
|
45
|
-
return
|
|
70
|
+
return { body, meta };
|
|
46
71
|
}
|
|
72
|
+
/** Read just `guid`/`name` from frontmatter (used by GUID matching). */
|
|
73
|
+
export function parseFrontmatter(content) {
|
|
74
|
+
const { meta } = parsePrimaryContent(content);
|
|
75
|
+
const result = {};
|
|
76
|
+
if (typeof meta.guid === 'string')
|
|
77
|
+
result.guid = meta.guid;
|
|
78
|
+
if (typeof meta.name === 'string')
|
|
79
|
+
result.name = meta.name;
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
// ── Mode normalization ───────────────────────────────────────────────────────
|
|
47
83
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
84
|
+
* The API only accepts the backend `mode` enum (`auto` | `referenced` | `always`),
|
|
85
|
+
* but on disk (and in the Xano UI) this is the `inclusion` field with values
|
|
86
|
+
* "on demand" / "manual" / "always" — see `modeToInclusion` for the pull-side
|
|
87
|
+
* mapping. Hand-authored frontmatter (or content copied from the UI) commonly
|
|
88
|
+
* uses the display label instead, which the API rejects with e.g. `Input
|
|
89
|
+
* "on demand" is not one of the allowable values.` (DEV-7380/DEV-7382).
|
|
90
|
+
* Normalize known aliases before every push so the backend enum value is
|
|
91
|
+
* always what's sent.
|
|
50
92
|
*/
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return
|
|
93
|
+
const MODE_ALIASES = {
|
|
94
|
+
'always included': 'always',
|
|
95
|
+
'manual': 'referenced',
|
|
96
|
+
'on demand': 'auto',
|
|
97
|
+
};
|
|
98
|
+
function normalizeMode(mode) {
|
|
99
|
+
return MODE_ALIASES[mode.trim().toLowerCase()] ?? mode;
|
|
100
|
+
}
|
|
101
|
+
/** Map the backend `mode` enum to the `inclusion:` frontmatter value shown in the Xano UI. */
|
|
102
|
+
const MODE_TO_INCLUSION = {
|
|
103
|
+
always: 'always',
|
|
104
|
+
auto: 'on demand',
|
|
105
|
+
referenced: 'manual',
|
|
106
|
+
};
|
|
107
|
+
function modeToInclusion(mode) {
|
|
108
|
+
return MODE_TO_INCLUSION[mode] ?? mode;
|
|
58
109
|
}
|
|
59
|
-
// ──
|
|
60
|
-
const SKIPPED_DIRECTORIES = new Set(['node_modules']);
|
|
110
|
+
// ── Path mapping (CLI-owned layout) ────────────────────────────────────────────
|
|
61
111
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
112
|
+
* Allocate a unique snake_case folder/file segment per call, suffixing
|
|
113
|
+
* collisions with `_2`, `_3`, … . Each object instance gets its own segment —
|
|
114
|
+
* we deliberately do NOT memoize by name, because two distinct objects can
|
|
115
|
+
* share a name (identity travels via the frontmatter `guid`, not the path), and
|
|
116
|
+
* collapsing them onto one path would silently overwrite files.
|
|
64
117
|
*/
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
118
|
+
function makeNameResolver() {
|
|
119
|
+
const taken = new Set();
|
|
120
|
+
return (name) => {
|
|
121
|
+
const base = snakeCase(name) || 'untitled';
|
|
122
|
+
let candidate = base;
|
|
123
|
+
let n = 2;
|
|
124
|
+
while (taken.has(candidate)) {
|
|
125
|
+
candidate = `${base}_${n}`;
|
|
126
|
+
n++;
|
|
127
|
+
}
|
|
128
|
+
taken.add(candidate);
|
|
129
|
+
return candidate;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** POSIX path (relative to `knowledge/`) of an object's primary `.md` file. */
|
|
133
|
+
function primaryRelPath(obj, resolvers) {
|
|
134
|
+
switch (obj.knowledge_type) {
|
|
135
|
+
case 'agents.md': {
|
|
136
|
+
return 'agents.md';
|
|
137
|
+
}
|
|
138
|
+
case 'doc': {
|
|
139
|
+
return `docs/${resolvers.doc(obj.name)}.md`;
|
|
76
140
|
}
|
|
77
|
-
|
|
78
|
-
|
|
141
|
+
case 'skill': {
|
|
142
|
+
return `skills/${resolvers.skill(obj.name)}/SKILL.md`;
|
|
79
143
|
}
|
|
80
144
|
}
|
|
81
|
-
return files.sort();
|
|
82
145
|
}
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
filePath,
|
|
91
|
-
name: relative(inputDir, filePath).split(sep).join('/'),
|
|
92
|
-
}));
|
|
146
|
+
/** Infer the knowledge type from a primary file's POSIX path. */
|
|
147
|
+
function inferType(posixPath) {
|
|
148
|
+
if (posixPath.startsWith('skills/'))
|
|
149
|
+
return 'skill';
|
|
150
|
+
if (posixPath.startsWith('docs/'))
|
|
151
|
+
return 'doc';
|
|
152
|
+
return 'agents.md';
|
|
93
153
|
}
|
|
94
|
-
// ──
|
|
154
|
+
// ── Pull: write objects to disk ────────────────────────────────────────────────
|
|
95
155
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
156
|
+
* Write knowledge objects under `<outputDir>/knowledge/`, building frontmatter
|
|
157
|
+
* and laying out skill reference files under `<skill>/references/`.
|
|
158
|
+
* Returns the number of files written (primaries + references).
|
|
98
159
|
*/
|
|
99
|
-
export function
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
plan.updates.push({ entry, remote });
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
for (const item of remoteItems) {
|
|
120
|
-
if (!localNames.has(item.name)) {
|
|
121
|
-
plan.deletes.push(item);
|
|
160
|
+
export function writeKnowledge(objects, outputDir) {
|
|
161
|
+
const resolvers = { doc: makeNameResolver(), skill: makeNameResolver() };
|
|
162
|
+
let count = 0;
|
|
163
|
+
for (const obj of objects) {
|
|
164
|
+
const rel = primaryRelPath(obj, resolvers);
|
|
165
|
+
const primaryAbs = join(outputDir, KNOWLEDGE_DIR, ...rel.split('/'));
|
|
166
|
+
fs.mkdirSync(dirname(primaryAbs), { recursive: true });
|
|
167
|
+
fs.writeFileSync(primaryAbs, buildPrimaryContent(obj), 'utf8');
|
|
168
|
+
count++;
|
|
169
|
+
if (obj.knowledge_type === 'skill' && obj.files?.length) {
|
|
170
|
+
const refsDir = join(dirname(primaryAbs), 'references');
|
|
171
|
+
for (const file of obj.files) {
|
|
172
|
+
const refAbs = join(refsDir, ...file.name.split('/'));
|
|
173
|
+
fs.mkdirSync(dirname(refAbs), { recursive: true });
|
|
174
|
+
fs.writeFileSync(refAbs, file.content, 'utf8');
|
|
175
|
+
count++;
|
|
176
|
+
}
|
|
122
177
|
}
|
|
123
178
|
}
|
|
124
|
-
return
|
|
179
|
+
return count;
|
|
125
180
|
}
|
|
126
|
-
// ──
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
instanceHost = new URL(opts.instanceOrigin).hostname;
|
|
133
|
-
}
|
|
134
|
-
catch { }
|
|
135
|
-
log(ux.colorize('dim', ` instance: ${instanceHost} | cli: v${opts.cliVersion}`));
|
|
136
|
-
log('');
|
|
137
|
-
const parts = [];
|
|
138
|
-
if (plan.creates.length > 0) {
|
|
139
|
-
parts.push(ux.colorize('green', `+${plan.creates.length} created`));
|
|
140
|
-
}
|
|
141
|
-
if (plan.updates.length > 0) {
|
|
142
|
-
parts.push(ux.colorize('yellow', `~${plan.updates.length} updated`));
|
|
143
|
-
}
|
|
144
|
-
if (opts.willDelete && plan.deletes.length > 0) {
|
|
145
|
-
parts.push(ux.colorize('red', `-${plan.deletes.length} deleted`));
|
|
146
|
-
}
|
|
147
|
-
if (parts.length > 0) {
|
|
148
|
-
log(` ${'Knowledge'.padEnd(20)} ${parts.join(' ')}`);
|
|
149
|
-
}
|
|
150
|
-
if (plan.creates.length > 0 || plan.updates.length > 0) {
|
|
151
|
-
log('');
|
|
152
|
-
log(ux.colorize('bold', '--- Changes ---'));
|
|
153
|
-
log('');
|
|
154
|
-
for (const entry of plan.creates) {
|
|
155
|
-
log(` ${ux.colorize('green', 'CREATE'.padEnd(16))} ${'knowledge'.padEnd(18)} ${entry.name}`);
|
|
181
|
+
// ── Push: collect objects from disk ────────────────────────────────────────────
|
|
182
|
+
function listFilesRecursive(baseDir, currentDir, out) {
|
|
183
|
+
for (const item of fs.readdirSync(currentDir, { withFileTypes: true })) {
|
|
184
|
+
const fullPath = join(currentDir, item.name);
|
|
185
|
+
if (item.isDirectory()) {
|
|
186
|
+
listFilesRecursive(baseDir, fullPath, out);
|
|
156
187
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
if (opts.willDelete && plan.deletes.length > 0) {
|
|
162
|
-
log('');
|
|
163
|
-
log(ux.colorize('bold', '--- Destructive Operations ---'));
|
|
164
|
-
log('');
|
|
165
|
-
for (const item of plan.deletes) {
|
|
166
|
-
log(` ${ux.colorize('red', 'DELETE'.padEnd(16))} ${'knowledge'.padEnd(18)} ${item.name}`);
|
|
188
|
+
else if (item.isFile()) {
|
|
189
|
+
out.push(relative(baseDir, fullPath).split(sep).join('/'));
|
|
167
190
|
}
|
|
168
191
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
192
|
+
}
|
|
193
|
+
/** True if `posixPath` is a primary file (not a reference). */
|
|
194
|
+
function isPrimary(posixPath) {
|
|
195
|
+
if (posixPath === 'agents.md')
|
|
196
|
+
return true;
|
|
197
|
+
if (/^skills\/[^/]+\/SKILL\.md$/.test(posixPath))
|
|
198
|
+
return true;
|
|
199
|
+
if (/^docs\/[^/]+\.md$/.test(posixPath))
|
|
200
|
+
return true;
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Walk `<inputDir>/knowledge/`, reconstructing structured knowledge objects from
|
|
205
|
+
* primary `.md` files (frontmatter → fields, body → content) and attaching each
|
|
206
|
+
* skill's `references/` files. Include/exclude globs are matched against
|
|
207
|
+
* `knowledge/<path>` (relative to inputDir), consistent with multidoc filtering.
|
|
208
|
+
* Returns `[]` when the directory is absent.
|
|
209
|
+
*/
|
|
210
|
+
export function collectKnowledgeObjects(inputDir, include, exclude) {
|
|
211
|
+
const knowledgeDir = join(inputDir, KNOWLEDGE_DIR);
|
|
212
|
+
if (!fs.existsSync(knowledgeDir))
|
|
213
|
+
return [];
|
|
214
|
+
const allPaths = [];
|
|
215
|
+
listFilesRecursive(knowledgeDir, knowledgeDir, allPaths);
|
|
216
|
+
const allowed = (posixPath) => {
|
|
217
|
+
const rel = `${KNOWLEDGE_DIR}/${posixPath}`;
|
|
218
|
+
if (include?.length && !include.some((p) => minimatch(rel, p, { matchBase: true })))
|
|
219
|
+
return false;
|
|
220
|
+
if (exclude?.length && exclude.some((p) => minimatch(rel, p, { matchBase: true })))
|
|
221
|
+
return false;
|
|
222
|
+
return true;
|
|
223
|
+
};
|
|
224
|
+
const objects = [];
|
|
225
|
+
for (const posixPath of allPaths.sort()) {
|
|
226
|
+
if (!isPrimary(posixPath) || !allowed(posixPath))
|
|
227
|
+
continue;
|
|
228
|
+
const absPath = join(knowledgeDir, ...posixPath.split('/'));
|
|
229
|
+
const raw = fs.readFileSync(absPath, 'utf8');
|
|
230
|
+
const { body, meta } = parsePrimaryContent(raw);
|
|
231
|
+
const knowledgeType = meta.knowledge_type === 'agents.md' || meta.knowledge_type === 'doc' || meta.knowledge_type === 'skill'
|
|
232
|
+
? meta.knowledge_type
|
|
233
|
+
: inferType(posixPath);
|
|
234
|
+
// The API requires description/scope/mode/enabled on every item, so default
|
|
235
|
+
// them when a hand-authored file omits them (pulled files always carry them).
|
|
236
|
+
// `mode` is read from the on-disk `inclusion:` field (see modeToInclusion).
|
|
237
|
+
const obj = {
|
|
238
|
+
content: body,
|
|
239
|
+
description: typeof meta.description === 'string' ? meta.description : '',
|
|
240
|
+
enabled: typeof meta.enabled === 'boolean' ? meta.enabled : true,
|
|
241
|
+
filePath: absPath,
|
|
242
|
+
knowledge_type: knowledgeType,
|
|
243
|
+
mode: typeof meta.inclusion === 'string' ? normalizeMode(meta.inclusion) : 'auto',
|
|
244
|
+
name: typeof meta.name === 'string' ? meta.name : deriveName(posixPath),
|
|
245
|
+
scope: typeof meta.scope === 'string' ? meta.scope : 'workspace',
|
|
246
|
+
};
|
|
247
|
+
if (typeof meta.guid === 'string')
|
|
248
|
+
obj.guid = meta.guid;
|
|
249
|
+
if (knowledgeType === 'skill') {
|
|
250
|
+
const skillFolder = posixPath.slice('skills/'.length, posixPath.indexOf('/SKILL.md'));
|
|
251
|
+
const refPrefix = `skills/${skillFolder}/references/`;
|
|
252
|
+
const files = [];
|
|
253
|
+
for (const candidate of allPaths) {
|
|
254
|
+
if (candidate.startsWith(refPrefix) && allowed(candidate)) {
|
|
255
|
+
files.push({
|
|
256
|
+
content: fs.readFileSync(join(knowledgeDir, ...candidate.split('/')), 'utf8'),
|
|
257
|
+
name: candidate.slice(refPrefix.length),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (files.length > 0)
|
|
262
|
+
obj.files = files;
|
|
176
263
|
}
|
|
177
|
-
|
|
178
|
-
log(ux.colorize('dim', ` Use --delete to remove these ${plan.deletes.length} item(s) from remote.`));
|
|
264
|
+
objects.push(obj);
|
|
179
265
|
}
|
|
180
|
-
|
|
266
|
+
return objects;
|
|
181
267
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
268
|
+
function deriveName(posixPath) {
|
|
269
|
+
if (posixPath === 'agents.md')
|
|
270
|
+
return 'agents.md';
|
|
271
|
+
const parts = posixPath.split('/');
|
|
272
|
+
if (posixPath.startsWith('skills/'))
|
|
273
|
+
return parts[1];
|
|
274
|
+
return parts.at(-1).replace(/\.md$/, '');
|
|
185
275
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
Authorization: `Bearer ${accessToken}`,
|
|
190
|
-
'Content-Type': 'application/json',
|
|
191
|
-
};
|
|
276
|
+
/** Strip the local-only `filePath` before sending objects to the API. */
|
|
277
|
+
export function toPushItems(objects) {
|
|
278
|
+
return objects.map(({ filePath: _filePath, ...rest }) => rest);
|
|
192
279
|
}
|
|
280
|
+
// ── API calls ──────────────────────────────────────────────────────────────────
|
|
193
281
|
/**
|
|
194
|
-
*
|
|
282
|
+
* GET workspace knowledge objects (with content). Returns `[]` on 404 (instance
|
|
283
|
+
* without the feature) or any network error, so pull degrades gracefully.
|
|
195
284
|
*/
|
|
196
|
-
export async function
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
285
|
+
export async function fetchKnowledge(baseUrl, branch, accessToken, verboseFetch, verbose) {
|
|
286
|
+
const params = new URLSearchParams({ branch });
|
|
287
|
+
const url = `${baseUrl}?${params.toString()}`;
|
|
288
|
+
try {
|
|
289
|
+
const response = await verboseFetch(url, { headers: { accept: 'application/json', Authorization: `Bearer ${accessToken}` }, method: 'GET' }, verbose, accessToken);
|
|
290
|
+
if (!response.ok)
|
|
291
|
+
return [];
|
|
292
|
+
const json = (await response.json());
|
|
293
|
+
if (Array.isArray(json))
|
|
294
|
+
return json;
|
|
295
|
+
return json.items ?? [];
|
|
206
296
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (!items) {
|
|
210
|
-
throw new Error('unexpected response format from knowledge list endpoint');
|
|
297
|
+
catch {
|
|
298
|
+
return [];
|
|
211
299
|
}
|
|
212
|
-
// Records without a name can't be matched to a local file path, so they are
|
|
213
|
-
// invisible to push/pull (the API also rejects updates/deletes on them).
|
|
214
|
-
return items.filter((item) => typeof item.name === 'string' && item.name !== '');
|
|
215
300
|
}
|
|
216
|
-
// ── Main Push Logic ─────────────────────────────────────────────────────────
|
|
217
301
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* Mirrors the UX of `workspace push` (partial by default, --sync for full,
|
|
221
|
-
* --delete to remove remote-only items, --dry-run / --force).
|
|
302
|
+
* POST knowledge objects to the workspace. Throws on non-2xx. When `body.dry_run`
|
|
303
|
+
* is set the result may carry `operations`/`summary` instead of `imported`.
|
|
222
304
|
*/
|
|
223
|
-
export async function
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (flags['dry-run'] || !flags.force) {
|
|
246
|
-
renderKnowledgePreview(plan, {
|
|
247
|
-
cliVersion: ctx.cliVersion,
|
|
248
|
-
instanceOrigin: ctx.instanceOrigin,
|
|
249
|
-
label,
|
|
250
|
-
partial: isPartial,
|
|
251
|
-
willDelete: shouldDelete,
|
|
252
|
-
}, log);
|
|
253
|
-
}
|
|
254
|
-
const hasChanges = plan.creates.length > 0 || plan.updates.length > 0 || (shouldDelete && plan.deletes.length > 0);
|
|
255
|
-
if (!hasChanges) {
|
|
256
|
-
log('No changes to push.');
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
if (flags['dry-run']) {
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
if (!flags.force && !(await confirmKnowledgePush(command, shouldDelete && plan.deletes.length > 0))) {
|
|
263
|
-
log('Push cancelled.');
|
|
264
|
-
return;
|
|
305
|
+
export async function pushKnowledge(url, accessToken, verboseFetch, verbose, body) {
|
|
306
|
+
const response = await verboseFetch(url, {
|
|
307
|
+
body: JSON.stringify(body),
|
|
308
|
+
headers: {
|
|
309
|
+
accept: 'application/json',
|
|
310
|
+
Authorization: `Bearer ${accessToken}`,
|
|
311
|
+
'Content-Type': 'application/json',
|
|
312
|
+
},
|
|
313
|
+
method: 'POST',
|
|
314
|
+
}, verbose, accessToken);
|
|
315
|
+
if (!response.ok) {
|
|
316
|
+
const errorText = await response.text();
|
|
317
|
+
let msg = `Knowledge push failed (${response.status})`;
|
|
318
|
+
try {
|
|
319
|
+
const json = JSON.parse(errorText);
|
|
320
|
+
if (json.message)
|
|
321
|
+
msg += `: ${json.message}`;
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
msg += `\n${errorText}`;
|
|
325
|
+
}
|
|
326
|
+
throw new Error(msg);
|
|
265
327
|
}
|
|
266
|
-
// ── Apply ─────────────────────────────────────────────────────────────
|
|
267
|
-
const startTime = Date.now();
|
|
268
|
-
let applied = '';
|
|
269
328
|
try {
|
|
270
|
-
|
|
329
|
+
return (await response.json());
|
|
271
330
|
}
|
|
272
|
-
catch
|
|
273
|
-
|
|
331
|
+
catch {
|
|
332
|
+
return {};
|
|
274
333
|
}
|
|
275
|
-
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
276
|
-
log(`Pushed knowledge to ${label} from ${relative(process.cwd(), inputDir) || inputDir} in ${elapsed}s (${applied}, ${plan.unchanged.length} unchanged)`);
|
|
277
334
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const invalid = entries
|
|
293
|
-
.map((entry) => ({ entry, reason: validateKnowledgeName(entry.name) }))
|
|
294
|
-
.filter((item) => item.reason !== null);
|
|
295
|
-
if (invalid.length > 0) {
|
|
296
|
-
const lines = invalid.map(({ entry, reason }) => ` ${entry.name} — ${reason}`);
|
|
297
|
-
command.error(`Cannot push: ${invalid.length} file(s) have unsupported names:\n${lines.join('\n')}`);
|
|
298
|
-
}
|
|
299
|
-
return entries;
|
|
335
|
+
// ── Preview (client-side diff fallback) ─────────────────────────────────────────
|
|
336
|
+
/** Canonical form for change detection: content + description + metadata + references. */
|
|
337
|
+
function canonical(obj) {
|
|
338
|
+
const files = (obj.files ?? [])
|
|
339
|
+
.map((f) => ({ content: f.content, name: f.name }))
|
|
340
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
341
|
+
return JSON.stringify({
|
|
342
|
+
content: obj.content,
|
|
343
|
+
description: obj.description ?? '',
|
|
344
|
+
enabled: obj.enabled ?? null,
|
|
345
|
+
files,
|
|
346
|
+
mode: obj.mode ?? '',
|
|
347
|
+
scope: obj.scope ?? '',
|
|
348
|
+
});
|
|
300
349
|
}
|
|
301
350
|
/**
|
|
302
|
-
*
|
|
303
|
-
*
|
|
351
|
+
* Compute a dry-run-style preview by diffing local objects against server
|
|
352
|
+
* objects (match by `guid`, then `name`). Used when the server doesn't honor
|
|
353
|
+
* the `dry_run` flag.
|
|
304
354
|
*/
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
355
|
+
export function knowledgePreview(local, server, willDelete) {
|
|
356
|
+
const operations = [];
|
|
357
|
+
const summary = {};
|
|
358
|
+
const ensure = (type) => {
|
|
359
|
+
summary[type] ??= { created: 0, deleted: 0, unchanged: 0, updated: 0 };
|
|
360
|
+
};
|
|
361
|
+
const serverByGuid = new Map();
|
|
362
|
+
const serverByName = new Map();
|
|
363
|
+
for (const s of server) {
|
|
364
|
+
if (s.guid)
|
|
365
|
+
serverByGuid.set(s.guid, s);
|
|
366
|
+
serverByName.set(s.name, s);
|
|
367
|
+
}
|
|
368
|
+
const matched = new Set();
|
|
369
|
+
for (const obj of local) {
|
|
370
|
+
const type = obj.knowledge_type;
|
|
371
|
+
ensure(type);
|
|
372
|
+
const match = (obj.guid ? serverByGuid.get(obj.guid) : undefined) ?? serverByName.get(obj.name);
|
|
373
|
+
if (match) {
|
|
374
|
+
matched.add(match);
|
|
375
|
+
if (canonical(match) === canonical(obj)) {
|
|
376
|
+
summary[type].unchanged++;
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
operations.push({ action: 'update', name: obj.name, type });
|
|
380
|
+
summary[type].updated++;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
operations.push({ action: 'create', name: obj.name, type });
|
|
385
|
+
summary[type].created++;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (willDelete) {
|
|
389
|
+
for (const s of server) {
|
|
390
|
+
if (!matched.has(s)) {
|
|
391
|
+
const type = s.knowledge_type;
|
|
392
|
+
ensure(type);
|
|
393
|
+
operations.push({ action: 'delete', name: s.name, type });
|
|
394
|
+
summary[type].deleted++;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
308
397
|
}
|
|
309
|
-
|
|
310
|
-
? 'Proceed with push? This includes DESTRUCTIVE operations listed above.'
|
|
311
|
-
: 'Proceed with push?';
|
|
312
|
-
return confirm(message);
|
|
398
|
+
return { operations, summary };
|
|
313
399
|
}
|
|
400
|
+
// ── GUID writeback ──────────────────────────────────────────────────────────────
|
|
314
401
|
/**
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
* shouldDelete is set. Returns a "2 created, 1 updated" style summary.
|
|
318
|
-
* Throws on the first failed request, noting what was applied so far.
|
|
402
|
+
* Set or update `guid:` in a primary `.md` file's YAML frontmatter.
|
|
403
|
+
* Returns true if the file was modified.
|
|
319
404
|
*/
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if (deleted > 0)
|
|
331
|
-
counts.push(`${deleted} deleted`);
|
|
332
|
-
return counts.join(', ') || 'no changes applied';
|
|
333
|
-
};
|
|
334
|
-
// eslint-disable-next-line no-undef
|
|
335
|
-
const request = async (action, name, url, init) => {
|
|
336
|
-
const response = await ctx.verboseFetch(url, init, verbose, ctx.accessToken);
|
|
337
|
-
if (!response.ok) {
|
|
338
|
-
throw new Error(`failed to ${action} "${name}" (${response.status}): ${await response.text()} (after ${describeApplied()})`);
|
|
405
|
+
export function syncGuidToFrontmatter(filePath, guid) {
|
|
406
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
407
|
+
const lines = content.split('\n');
|
|
408
|
+
if (lines[0]?.trim() !== '---')
|
|
409
|
+
return false;
|
|
410
|
+
let close = -1;
|
|
411
|
+
for (let i = 1; i < lines.length; i++) {
|
|
412
|
+
if (lines[i].trim() === '---') {
|
|
413
|
+
close = i;
|
|
414
|
+
break;
|
|
339
415
|
}
|
|
340
|
-
};
|
|
341
|
-
/* eslint-disable no-await-in-loop */
|
|
342
|
-
for (const entry of plan.creates) {
|
|
343
|
-
const body = {
|
|
344
|
-
content: entry.content,
|
|
345
|
-
// eslint-disable-next-line camelcase
|
|
346
|
-
knowledge_type: inferKnowledgeType(entry.name),
|
|
347
|
-
name: entry.name,
|
|
348
|
-
};
|
|
349
|
-
if (ctx.branch) {
|
|
350
|
-
body.branch = ctx.branch;
|
|
351
|
-
}
|
|
352
|
-
await request('create', entry.name, knowledgeUrl(ctx), {
|
|
353
|
-
body: JSON.stringify(body),
|
|
354
|
-
headers: jsonHeaders(ctx.accessToken),
|
|
355
|
-
method: 'POST',
|
|
356
|
-
});
|
|
357
|
-
created++;
|
|
358
|
-
}
|
|
359
|
-
for (const { entry, remote } of plan.updates) {
|
|
360
|
-
// name is included because the server requires it on edit; other metadata
|
|
361
|
-
// (description, mode, enabled, tags, ...) is left untouched.
|
|
362
|
-
await request('update', entry.name, knowledgeUrl(ctx, `/${remote.id}`), {
|
|
363
|
-
body: JSON.stringify({ content: entry.content, name: entry.name }),
|
|
364
|
-
headers: jsonHeaders(ctx.accessToken),
|
|
365
|
-
method: 'PUT',
|
|
366
|
-
});
|
|
367
|
-
updated++;
|
|
368
416
|
}
|
|
369
|
-
if (
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
417
|
+
if (close === -1)
|
|
418
|
+
return false;
|
|
419
|
+
for (let i = 1; i < close; i++) {
|
|
420
|
+
const match = lines[i].match(/^guid\s*:\s*(.+)$/);
|
|
421
|
+
if (match) {
|
|
422
|
+
const existing = match[1].trim().replaceAll(/^["']|["']$/g, '');
|
|
423
|
+
if (existing === guid)
|
|
424
|
+
return false;
|
|
425
|
+
lines[i] = `guid: ${guid}`;
|
|
426
|
+
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
|
|
427
|
+
return true;
|
|
376
428
|
}
|
|
377
429
|
}
|
|
378
|
-
|
|
379
|
-
|
|
430
|
+
lines.splice(close, 0, `guid: ${guid}`);
|
|
431
|
+
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
|
|
432
|
+
return true;
|
|
380
433
|
}
|