@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.
Files changed (81) hide show
  1. package/README.md +139 -4
  2. package/dist/base-command.d.ts +27 -0
  3. package/dist/base-command.js +124 -1
  4. package/dist/commands/auth/index.d.ts +45 -1
  5. package/dist/commands/auth/index.js +197 -29
  6. package/dist/commands/ephemeral/create/index.d.ts +18 -0
  7. package/dist/commands/ephemeral/create/index.js +103 -0
  8. package/dist/commands/ephemeral/delete/index.d.ts +18 -0
  9. package/dist/commands/ephemeral/delete/index.js +99 -0
  10. package/dist/commands/ephemeral/edit/index.d.ts +18 -0
  11. package/dist/commands/ephemeral/edit/index.js +109 -0
  12. package/dist/commands/ephemeral/get/index.d.ts +21 -0
  13. package/dist/commands/ephemeral/get/index.js +125 -0
  14. package/dist/commands/ephemeral/impersonate/index.d.ts +19 -0
  15. package/dist/commands/ephemeral/impersonate/index.js +112 -0
  16. package/dist/commands/ephemeral/list/index.d.ts +19 -0
  17. package/dist/commands/ephemeral/list/index.js +122 -0
  18. package/dist/commands/ephemeral/pull/index.d.ts +23 -0
  19. package/dist/commands/ephemeral/pull/index.js +248 -0
  20. package/dist/commands/{knowledge → ephemeral}/push/index.d.ts +8 -5
  21. package/dist/commands/ephemeral/push/index.js +143 -0
  22. package/dist/commands/function/run/index.d.ts +40 -0
  23. package/dist/commands/function/run/index.js +244 -0
  24. package/dist/commands/knowledge/{pull → get}/index.d.ts +6 -2
  25. package/dist/commands/knowledge/get/index.js +126 -0
  26. package/dist/commands/knowledge/list/index.d.ts +18 -0
  27. package/dist/commands/knowledge/list/index.js +135 -0
  28. package/dist/commands/sandbox/pull/index.js +12 -2
  29. package/dist/commands/sandbox/push/index.d.ts +1 -1
  30. package/dist/commands/sandbox/push/index.js +19 -14
  31. package/dist/commands/sandbox/reset/index.js +12 -0
  32. package/dist/commands/static_host/build/create/index.d.ts +9 -1
  33. package/dist/commands/static_host/build/create/index.js +54 -4
  34. package/dist/commands/static_host/build/delete/index.d.ts +19 -0
  35. package/dist/commands/static_host/build/delete/index.js +114 -0
  36. package/dist/commands/static_host/build/get/index.d.ts +1 -1
  37. package/dist/commands/static_host/build/get/index.js +16 -10
  38. package/dist/commands/static_host/build/pull/index.d.ts +52 -0
  39. package/dist/commands/static_host/build/pull/index.js +300 -0
  40. package/dist/commands/static_host/build/push/index.d.ts +23 -0
  41. package/dist/commands/static_host/build/push/index.js +228 -0
  42. package/dist/commands/static_host/create/index.d.ts +17 -0
  43. package/dist/commands/static_host/create/index.js +86 -0
  44. package/dist/commands/static_host/deploy/index.d.ts +18 -0
  45. package/dist/commands/static_host/deploy/index.js +105 -0
  46. package/dist/commands/static_host/edit/index.d.ts +23 -0
  47. package/dist/commands/static_host/edit/index.js +151 -0
  48. package/dist/commands/static_host/get/index.d.ts +18 -0
  49. package/dist/commands/static_host/get/index.js +94 -0
  50. package/dist/commands/static_host/migrate/index.d.ts +44 -0
  51. package/dist/commands/static_host/migrate/index.js +205 -0
  52. package/dist/commands/tenant/snapshot/create/index.d.ts +17 -0
  53. package/dist/commands/tenant/snapshot/create/index.js +78 -0
  54. package/dist/commands/tenant/snapshot/delete/index.d.ts +19 -0
  55. package/dist/commands/tenant/snapshot/delete/index.js +102 -0
  56. package/dist/commands/tenant/snapshot/list/index.d.ts +16 -0
  57. package/dist/commands/tenant/snapshot/list/index.js +96 -0
  58. package/dist/commands/tenant/snapshot/swap/index.d.ts +19 -0
  59. package/dist/commands/tenant/snapshot/swap/index.js +103 -0
  60. package/dist/commands/tenant/unit_test/run/index.d.ts +1 -0
  61. package/dist/commands/tenant/unit_test/run/index.js +7 -0
  62. package/dist/commands/tenant/unit_test/run_all/index.js +7 -3
  63. package/dist/commands/unit_test/run/index.d.ts +1 -0
  64. package/dist/commands/unit_test/run/index.js +8 -0
  65. package/dist/commands/unit_test/run_all/index.js +7 -3
  66. package/dist/commands/workspace/pull/index.js +13 -2
  67. package/dist/commands/workspace/push/index.d.ts +1 -1
  68. package/dist/commands/workspace/push/index.js +15 -5
  69. package/dist/utils/function-input.d.ts +77 -0
  70. package/dist/utils/function-input.js +148 -0
  71. package/dist/utils/knowledge-sync.d.ts +84 -79
  72. package/dist/utils/knowledge-sync.js +376 -323
  73. package/dist/utils/multidoc-push.d.ts +30 -18
  74. package/dist/utils/multidoc-push.js +284 -148
  75. package/dist/utils/reference-checker.js +2 -2
  76. package/dist/utils/static-host-files.d.ts +21 -0
  77. package/dist/utils/static-host-files.js +41 -0
  78. package/oclif.manifest.json +5046 -2691
  79. package/package.json +10 -1
  80. package/dist/commands/knowledge/pull/index.js +0 -86
  81. package/dist/commands/knowledge/push/index.js +0 -126
@@ -1,380 +1,433 @@
1
- import { ux } from '@oclif/core';
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, resolve, sep } from 'node:path';
4
- import { applyFilters, confirm } from './multidoc-push.js';
5
- // ── Name & Path Safety ──────────────────────────────────────────────────────
6
- // Mirrors the Metadata API's name filter: text|min(1)|max(200)|alphaOk|digitOk|ok("/_-{}. ")
7
- const NAME_PATTERN = /^[\w/{}. -]+$/;
8
- export const MAX_NAME_LENGTH = 200;
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
- * Validate a knowledge name (a relative path like "some/thing/CLAUDE.md")
11
- * against the Metadata API's accepted character set and length.
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 validateKnowledgeName(name) {
15
- if (!name || name.trim() === '') {
16
- return 'name is empty';
17
- }
18
- if (name.length > MAX_NAME_LENGTH) {
19
- return `name exceeds ${MAX_NAME_LENGTH} characters`;
20
- }
21
- if (!NAME_PATTERN.test(name)) {
22
- return 'name contains unsupported characters (allowed: letters, digits, "/_-{}. ")';
23
- }
24
- return null;
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
- * Resolve a knowledge name to a local file path inside baseDir, rejecting
28
- * names that would escape it (absolute paths, "..", etc.).
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 safeKnowledgePath(baseDir, name) {
32
- // Normalize: knowledge names always use forward slashes
33
- if (!name || name.startsWith('/') || name.includes('\\')) {
34
- return null;
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
- const segments = name.split('/');
37
- if (segments.some((s) => s === '' || s === '.' || s === '..')) {
38
- return null;
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
- const base = resolve(baseDir);
41
- const target = resolve(base, ...segments);
42
- if (target !== base && !target.startsWith(base + sep)) {
43
- return null;
67
+ catch {
68
+ meta = {};
44
69
  }
45
- return target;
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
- * Infer the knowledge_type for a newly created knowledge item from its
49
- * filename. AGENTS.md and SKILL.md are special-cased; everything else is a doc.
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
- export function inferKnowledgeType(name) {
52
- const basename = name.split('/').pop()?.toLowerCase() ?? '';
53
- if (basename === 'agents.md')
54
- return 'agents.md';
55
- if (basename === 'skill.md')
56
- return 'skill';
57
- return 'doc';
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
- // ── File Collection ─────────────────────────────────────────────────────────
60
- const SKIPPED_DIRECTORIES = new Set(['node_modules']);
110
+ // ── Path mapping (CLI-owned layout) ────────────────────────────────────────────
61
111
  /**
62
- * Recursively collect all files under a directory, skipping hidden files,
63
- * hidden directories, and node_modules. Sorted for deterministic ordering.
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
- export function collectKnowledgeFiles(dir) {
66
- const files = [];
67
- const entries = fs.readdirSync(dir, { withFileTypes: true });
68
- for (const entry of entries) {
69
- if (entry.name.startsWith('.'))
70
- continue;
71
- const fullPath = join(dir, entry.name);
72
- if (entry.isDirectory()) {
73
- if (SKIPPED_DIRECTORIES.has(entry.name))
74
- continue;
75
- files.push(...collectKnowledgeFiles(fullPath));
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
- else if (entry.isFile()) {
78
- files.push(fullPath);
141
+ case 'skill': {
142
+ return `skills/${resolvers.skill(obj.name)}/SKILL.md`;
79
143
  }
80
144
  }
81
- return files.sort();
82
145
  }
83
- /**
84
- * Read local files into knowledge entries. The knowledge name is the path
85
- * relative to inputDir, using forward slashes.
86
- */
87
- export function readKnowledgeEntries(files, inputDir) {
88
- return files.map((filePath) => ({
89
- content: fs.readFileSync(filePath, 'utf8'),
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
- // ── Diff Plan ───────────────────────────────────────────────────────────────
154
+ // ── Pull: write objects to disk ────────────────────────────────────────────────
95
155
  /**
96
- * Compute the create/update/delete plan by matching local entries against
97
- * remote knowledge items by name.
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 buildKnowledgePlan(localEntries, remoteItems) {
100
- const remoteByName = new Map();
101
- for (const item of remoteItems) {
102
- remoteByName.set(item.name, item);
103
- }
104
- const plan = { creates: [], deletes: [], unchanged: [], updates: [] };
105
- const localNames = new Set();
106
- for (const entry of localEntries) {
107
- localNames.add(entry.name);
108
- const remote = remoteByName.get(entry.name);
109
- if (!remote) {
110
- plan.creates.push(entry);
111
- }
112
- else if ((remote.content ?? '') === entry.content) {
113
- plan.unchanged.push({ entry, remote });
114
- }
115
- else {
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 plan;
179
+ return count;
125
180
  }
126
- // ── Preview Rendering ───────────────────────────────────────────────────────
127
- export function renderKnowledgePreview(plan, opts, log) {
128
- log('');
129
- log(ux.colorize('bold', `=== Push Preview: ${opts.label} ===`));
130
- let instanceHost = opts.instanceOrigin;
131
- try {
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
- for (const { entry } of plan.updates) {
158
- log(` ${ux.colorize('yellow', 'UPDATE'.padEnd(16))} ${'knowledge'.padEnd(18)} ${entry.name}`);
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
- // Show remote-only items when not deleting (full sync only, to match workspace push)
170
- if (!opts.willDelete && !opts.partial && plan.deletes.length > 0) {
171
- log('');
172
- log(ux.colorize('dim', '--- Remote Only (not included in push) ---'));
173
- log('');
174
- for (const item of plan.deletes) {
175
- log(ux.colorize('dim', ` ${'knowledge'.padEnd(18)} ${item.name}`));
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
- log('');
178
- log(ux.colorize('dim', ` Use --delete to remove these ${plan.deletes.length} item(s) from remote.`));
264
+ objects.push(obj);
179
265
  }
180
- log('');
266
+ return objects;
181
267
  }
182
- // ── API Calls ───────────────────────────────────────────────────────────────
183
- function knowledgeUrl(ctx, suffix = '') {
184
- return `${ctx.instanceOrigin}/api:meta/workspace/${ctx.workspaceId}/knowledge${suffix}`;
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
- function jsonHeaders(accessToken) {
187
- return {
188
- accept: 'application/json',
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
- * Fetch all knowledge items (with content) for a workspace/branch.
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 fetchKnowledgeList(ctx, verbose) {
197
- // eslint-disable-next-line camelcase
198
- const queryParams = new URLSearchParams({ include_content: 'true' });
199
- if (ctx.branch) {
200
- queryParams.set('branch', ctx.branch);
201
- }
202
- const response = await ctx.verboseFetch(`${knowledgeUrl(ctx)}?${queryParams.toString()}`, { headers: jsonHeaders(ctx.accessToken), method: 'GET' }, verbose, ctx.accessToken);
203
- if (!response.ok) {
204
- const errorText = await response.text();
205
- throw new Error(`failed to list knowledge (${response.status}): ${errorText}`);
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
- const data = (await response.json());
208
- const items = Array.isArray(data) ? data : data && Array.isArray(data.items) ? data.items : null;
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
- * Execute a knowledge push: collect local files, diff against remote by name,
219
- * preview, confirm, then apply creates/updates/deletes through the Metadata API.
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 executeKnowledgePush(ctx, flags) {
224
- const { command, inputDir } = ctx;
225
- const log = command.log.bind(command);
226
- // ── Resolve push mode ─────────────────────────────────────────────────
227
- const isPartial = !flags.sync;
228
- if (flags.delete && isPartial) {
229
- command.error('Cannot use --delete without --sync');
230
- }
231
- const shouldDelete = isPartial ? false : flags.delete;
232
- // ── Collect, filter, and validate local files ─────────────────────────
233
- const entries = collectLocalKnowledge(command, inputDir, flags);
234
- // ── Diff against remote ───────────────────────────────────────────────
235
- let remoteItems = [];
236
- try {
237
- remoteItems = await fetchKnowledgeList(ctx, flags.verbose);
238
- }
239
- catch (error) {
240
- command.error(`Failed to fetch remote knowledge: ${error.message}`);
241
- }
242
- const plan = buildKnowledgePlan(entries, remoteItems);
243
- const label = `knowledge in workspace ${ctx.workspaceId}`;
244
- // ── Preview & confirm ─────────────────────────────────────────────────
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
- applied = await applyKnowledgePlan(ctx, plan, shouldDelete, flags.verbose);
329
+ return (await response.json());
271
330
  }
272
- catch (error) {
273
- command.error(`Push failed: ${error.message}`);
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
- * Collect local knowledge files, apply include/exclude filters, and validate
280
- * names against the API's accepted character set. Errors out on empty or
281
- * invalid input.
282
- */
283
- function collectLocalKnowledge(command, inputDir, flags) {
284
- const allFiles = collectKnowledgeFiles(inputDir);
285
- const files = applyFilters(allFiles, inputDir, flags.include, flags.exclude, command.log.bind(command));
286
- if (files.length === 0) {
287
- command.error(flags.include || flags.exclude
288
- ? `No knowledge files remain after include/exclude filters in ${inputDir}`
289
- : `No knowledge files found in ${inputDir}`);
290
- }
291
- const entries = readKnowledgeEntries(files, inputDir);
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
- * Prompt for confirmation before pushing. Errors out in non-interactive
303
- * environments (matching `workspace push`), where --force must be used instead.
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
- async function confirmKnowledgePush(command, hasDestructive) {
306
- if (!process.stdin.isTTY) {
307
- command.error('Non-interactive environment detected. Use --force to skip confirmation.');
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
- const message = hasDestructive
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
- * Apply a knowledge plan against the Metadata API: POST creates, PUT updates
316
- * (content only, so other metadata is preserved), and DELETE removals when
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
- async function applyKnowledgePlan(ctx, plan, shouldDelete, verbose) {
321
- let created = 0;
322
- let updated = 0;
323
- let deleted = 0;
324
- const describeApplied = () => {
325
- const counts = [];
326
- if (created > 0)
327
- counts.push(`${created} created`);
328
- if (updated > 0)
329
- counts.push(`${updated} updated`);
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 (shouldDelete) {
370
- for (const item of plan.deletes) {
371
- await request('delete', item.name, knowledgeUrl(ctx, `/${item.id}`), {
372
- headers: jsonHeaders(ctx.accessToken),
373
- method: 'DELETE',
374
- });
375
- deleted++;
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
- /* eslint-enable no-await-in-loop */
379
- return describeApplied();
430
+ lines.splice(close, 0, `guid: ${guid}`);
431
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
432
+ return true;
380
433
  }