@notis_ai/cli 0.2.0-beta.191.1 → 0.2.0-beta.197.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +485 -86
  2. package/config/portal-route-roots.json +47 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +32663 -6357
  4. package/dist/base-skills/notis-apps/SKILL.md +88 -67
  5. package/dist/base-skills/notis-apps/references/architecture.md +114 -167
  6. package/dist/base-skills/notis-apps/references/design.md +26 -107
  7. package/dist/base-skills/notis-apps/references/memory.md +58 -0
  8. package/dist/base-skills/notis-apps/references/reading.md +52 -74
  9. package/dist/base-skills/notis-apps/references/release.md +104 -142
  10. package/dist/base-skills/notis-apps/references/sdk.md +113 -60
  11. package/dist/base-skills/notis-apps/references/troubleshooting.md +22 -21
  12. package/dist/base-skills/notis-apps/references/views.md +142 -0
  13. package/dist/base-skills/notis-cli/SKILL.md +4 -4
  14. package/dist/base-skills/notis-cli/references/app-delivery.md +12 -11
  15. package/dist/base-skills/notis-cli/references/native-databases.md +6 -6
  16. package/dist/base-skills/notis-cli/references/tool-examples.md +4 -4
  17. package/dist/base-skills/notis-query/SKILL.md +34 -60
  18. package/dist/base-skills/notis-query/references/database-discovery.md +59 -69
  19. package/dist/base-skills/notis-query/references/documents.md +63 -50
  20. package/dist/base-skills/notis-query/references/query.md +57 -524
  21. package/dist/sdk/src/bundleModule.ts +79 -0
  22. package/dist/sdk/src/components/DocumentEditor.tsx +6 -3
  23. package/dist/sdk/src/components/DocumentPage.tsx +141 -0
  24. package/dist/sdk/src/components/RecordComponents.tsx +33 -0
  25. package/dist/sdk/src/components/RenderChartData.tsx +19 -0
  26. package/dist/sdk/src/config.ts +4 -26
  27. package/dist/sdk/src/documents.ts +2 -0
  28. package/dist/sdk/src/hooks/useAction.ts +28 -0
  29. package/dist/sdk/src/hooks/useCloudComputer.ts +35 -4
  30. package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
  31. package/dist/sdk/src/hooks/useHandover.ts +5 -4
  32. package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
  33. package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
  34. package/dist/sdk/src/hooks/useNotis.ts +3 -0
  35. package/dist/sdk/src/hooks/useNotisNavigation.ts +26 -11
  36. package/dist/sdk/src/hooks/useQuery.ts +7 -4
  37. package/dist/sdk/src/hooks/useViewParams.ts +121 -0
  38. package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
  39. package/dist/sdk/src/index.ts +36 -0
  40. package/dist/sdk/src/interactions/shortcuts.tsx +17 -3
  41. package/dist/sdk/src/nativeData.ts +39 -0
  42. package/dist/sdk/src/presentation.tsx +82 -0
  43. package/dist/sdk/src/provider.tsx +9 -4
  44. package/dist/sdk/src/queryCache.ts +5 -3
  45. package/dist/sdk/src/queryErrors.ts +51 -0
  46. package/dist/sdk/src/runtime.ts +146 -8
  47. package/dist/sdk/src/space.ts +425 -0
  48. package/dist/sdk/src/styles.css +96 -72
  49. package/dist/sdk/src/vite.ts +11 -1
  50. package/dist/skill-sync/index.js +458 -68
  51. package/dist/skill-sync/index.js.map +4 -4
  52. package/dist/skill-sync-worker.mjs +451 -77
  53. package/dist/space-harness/host.js +66 -0
  54. package/dist/view-renderer/dist/host.css +1 -0
  55. package/dist/view-renderer/dist/host.js +1042 -0
  56. package/dist/view-renderer/dist/theme.css +73 -0
  57. package/dist/view-renderer/src/dom.js +180 -0
  58. package/dist/view-renderer/src/errors.js +8 -0
  59. package/dist/view-renderer/src/host.tsx +173 -0
  60. package/dist/view-renderer/src/index.js +4 -0
  61. package/dist/view-renderer/src/pool.js +76 -0
  62. package/dist/view-renderer/src/remote-broker.js +59 -0
  63. package/dist/view-renderer/src/renderer.js +201 -0
  64. package/package.json +11 -5
  65. package/skills/notis-apps/cli.md +270 -163
  66. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +3 -3
  67. package/skills/notis-onboarding/BRIEF.md +7 -3
  68. package/src/command-specs/apps.js +1 -8
  69. package/src/command-specs/files.js +29 -0
  70. package/src/command-specs/handover.js +45 -16
  71. package/src/command-specs/helpers.js +1 -0
  72. package/src/command-specs/index.js +10 -4
  73. package/src/command-specs/skills.js +229 -0
  74. package/src/command-specs/spaces.js +572 -0
  75. package/src/command-specs/views.js +69 -0
  76. package/src/runtime/agent-browser.js +30 -7
  77. package/src/runtime/app-boundary-validator.js +7 -4
  78. package/src/runtime/app-data-literals.js +82 -0
  79. package/src/runtime/app-platform.js +321 -253
  80. package/src/runtime/confined-space-build.js +74 -0
  81. package/src/runtime/confined-space-verify.js +49 -0
  82. package/src/runtime/native-record-files.js +47 -0
  83. package/src/runtime/retired-sdk-files.js +20 -0
  84. package/src/runtime/skill-sync/cloud-client.ts +26 -4
  85. package/src/runtime/skill-sync/index.ts +154 -35
  86. package/src/runtime/skill-sync/local-scanner.ts +125 -17
  87. package/src/runtime/skill-sync/native-identity.ts +102 -0
  88. package/src/runtime/skill-sync/native-plan.ts +59 -0
  89. package/src/runtime/skill-sync/sync-plan.ts +16 -9
  90. package/src/runtime/skill-sync/types.ts +32 -6
  91. package/src/runtime/source-root.js +32 -0
  92. package/src/runtime/space-collection-scaffold.js +66 -0
  93. package/src/runtime/space-database-structures.js +229 -0
  94. package/src/runtime/space-fixture-broker.js +50 -0
  95. package/src/runtime/space-lock.js +337 -0
  96. package/src/runtime/space-platform.js +105 -0
  97. package/src/runtime/space-source.js +281 -0
  98. package/src/runtime/space-test-server.js +327 -0
  99. package/src/runtime/space-verification.js +89 -0
  100. package/src/runtime/space-verify-browser-worker.js +51 -0
  101. package/src/runtime/space-view-manifest.js +254 -0
  102. package/src/runtime/sync-skills.js +1 -1
  103. package/src/runtime/transport.js +8 -1
  104. package/src/runtime/view-render.js +49 -0
  105. package/src/space-harness-host.ts +103 -0
  106. package/template/app/globals.css +1 -0
  107. package/src/command-specs/reports.js +0 -97
@@ -0,0 +1,572 @@
1
+ import { scaffoldSpaceCollection } from '../runtime/space-collection-scaffold.js';
2
+ import { appFilesDigest, pullAppSource, resolveProjectDir, saveSpaceVerification, spacePublicationIntent } from '../runtime/app-platform.js';
3
+ import { buildSpaceArtifact, prepareSpaceRelease } from '../runtime/space-platform.js';
4
+ import { verifySpaceRelease } from '../runtime/space-verification.js';
5
+ import { loadSelectedSpace } from '../runtime/space-source.js';
6
+ import { refreshSpaceAuthoring } from '../runtime/space-database-structures.js';
7
+ import { RESOURCES_FILE, SKILLS_DIR, applyPublication, collectSkillFolders, computeSpaceSourceChanges, describeAppliedChanges,
8
+ lockAfterPull, readResourceList, readSpaceLock, resourceEntries, skillFolderFiles, writeResourceList, writeSkillFolder, writeSpaceLock, isFolderAlias } from '../runtime/space-lock.js';
9
+ import { CliError, usageError } from '../runtime/errors.js';
10
+ import { readFileSync } from 'node:fs';
11
+ import { resolve } from 'node:path';
12
+ import { httpRequest } from '../runtime/transport.js';
13
+ import { assertSourceRoot } from '../runtime/source-root.js';
14
+ import { parseJson } from './helpers.js';
15
+ import { createHash } from 'node:crypto';
16
+ import { viewRenderCommandSpec } from './views.js';
17
+
18
+ function selected(ctx) {
19
+ if (!ctx.options.space) throw usageError('Choose the independent Space with --space <key>.');
20
+ // build, verify, preview, deploy and inspect write or stage inside this folder.
21
+ return { directory: assertSourceRoot(resolveProjectDir(ctx.args.dir || '.')), key: ctx.options.space };
22
+ }
23
+
24
+ function requestId(ctx) {
25
+ const value = ctx.options.requestId || ctx.globalOptions.idempotencyKey;
26
+ if (!value || value.length > 200) throw usageError('Pass --request-id <stable-key>; keep it unchanged when retrying this intent.');
27
+ return value;
28
+ }
29
+
30
+ function revision(ctx) {
31
+ const value = Number(ctx.options.revision);
32
+ if (!Number.isSafeInteger(value) || value < 1) throw usageError('Pass the published --revision <number> from spaces get.');
33
+ return value;
34
+ }
35
+
36
+ function schemaRevision(ctx) {
37
+ if (ctx.options.schemaRevision === undefined) return undefined;
38
+ const value = Number(ctx.options.schemaRevision);
39
+ if (!Number.isSafeInteger(value) || value < 0) throw usageError('Pass the exact --schema-revision <number> returned by the native schema or query.');
40
+ return value;
41
+ }
42
+
43
+ function object(value, label) {
44
+ const parsed = parseJson(value, label);
45
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw usageError(`${label} must be a JSON object.`);
46
+ return parsed;
47
+ }
48
+
49
+ async function remote(ctx, operation, { method = 'POST', body, query } = {}) {
50
+ const suffix = query ? `?${new URLSearchParams(Object.entries(query).filter(([, value]) => value != null))}` : '';
51
+ const result = await httpRequest({ runtime: ctx.runtime, method, path: `/portal_spaces/${operation}${suffix}`, body, spacesProtocol: 1 });
52
+ return ctx.output.emitSuccess({ command: ctx.spec.command_path.join(' '), data: result.payload,
53
+ humanSummary: JSON.stringify(result.payload, null, 2), meta: { mutating: method !== 'GET' && !body?.dry_run && ctx.spec.mutates !== false, request_id: result.requestId } });
54
+ }
55
+
56
+ /**
57
+ * What changed since the pull in `skills/` and `resources.json` (R5). Without a
58
+ * lock file the workspace was authored, not pulled: those files are ignored and
59
+ * the caller is told so. `recorded` replaces the lock and list on disk with the
60
+ * ones a deploy intent was first made from (its Skill folders are still read).
61
+ */
62
+ function localSpaceChanges(directory, spaceId, manifest, recorded = null) {
63
+ const lock = recorded ? recorded.lock : readSpaceLock(directory);
64
+ const folders = collectSkillFolders(directory);
65
+ const resources = recorded ? recorded.resources ?? null : readResourceList(directory);
66
+ if (!lock) {
67
+ const warnings = Object.keys(folders).length || resources
68
+ ? [`${SKILLS_DIR}/ and ${RESOURCES_FILE} are deployed only from a pulled Space (notis spaces pull); this workspace has no .notis/space-lock.json, so they were ignored.`]
69
+ : [];
70
+ return { lock: null, folders, changes: null, summary: null, fingerprint: null, warnings };
71
+ }
72
+ if (lock.space_id !== spaceId) {
73
+ throw usageError(`This workspace was pulled from Space ${lock.space_id}. Deploy it with --space-id ${lock.space_id}, or pull the other Space into another directory.`);
74
+ }
75
+ return { lock, resources, folders, ...computeSpaceSourceChanges({ lock, resources, folders, manifest }), warnings: [] };
76
+ }
77
+
78
+ /**
79
+ * The local changes a deploy intent stands for. A publication this request ID
80
+ * completed rewrote the lock and list (recordPublication), so the identical
81
+ * command finds nothing left to deploy against them: an untouched workspace is
82
+ * then checked against the lock and list the intent was first made from, and
83
+ * replays the same release. Anything edited since is still a different intent.
84
+ */
85
+ function intentChanges(directory, spaceId, manifest, local, intent) {
86
+ if (!intent?.local_state || local.fingerprint !== null
87
+ || (intent.local_changes_fingerprint ?? null) === local.fingerprint) return local;
88
+ const original = localSpaceChanges(directory, spaceId, manifest, intent.local_state);
89
+ return (original.fingerprint ?? null) === (intent.local_changes_fingerprint ?? null) ? original : local;
90
+ }
91
+
92
+ /** After a publication, the lock and list record the applied versions and the Space's current links. */
93
+ function recordPublication(directory, local, result) {
94
+ if (!local?.lock || !result || result.published_revision === undefined) return;
95
+ const updated = applyPublication({ lock: local.lock, folders: local.folders, result });
96
+ writeSpaceLock(directory, updated.lock);
97
+ if (updated.resources) writeResourceList(directory, updated.resources);
98
+ }
99
+
100
+ /** Reviewable: a page with viewer reads shows each signed-in viewer what they can open, with their own access. */
101
+ function describeViewerReads(capabilities) {
102
+ const families = capabilities?.viewerReads;
103
+ return Array.isArray(families) && families.length
104
+ ? ` Viewer reads: this page lists the ${families.join(' and ')} each signed-in viewer can open, with that viewer's own access (never on a Site).`
105
+ : '';
106
+ }
107
+
108
+ /** Reviewable: a page that uses the cloud computer runs on each viewer's own one, after that viewer allows it. */
109
+ function describeCloudComputer(capabilities) {
110
+ const level = capabilities?.cloudComputer;
111
+ if (level === 'shell') return " Cloud computer: this page runs commands and writes files on each signed-in viewer's own cloud computer, once that viewer allows it (never on a Site).";
112
+ if (level === 'read') return " Cloud computer: this page reads whether each signed-in viewer's own cloud computer is running and signed in to GitHub, once that viewer allows it.";
113
+ return '';
114
+ }
115
+
116
+ function describePendingChanges(summary) {
117
+ if (!summary) return '';
118
+ const parts = [];
119
+ if (summary.skills.edited.length) parts.push(`Skills to update: ${summary.skills.edited.join(', ')}`);
120
+ if (summary.skills.created.length) parts.push(`Skills to create: ${summary.skills.created.join(', ')}`);
121
+ if (summary.links.added.length) parts.push(`Links to add: ${summary.links.added.join(', ')}`);
122
+ if (summary.links.removed.length) parts.push(`Links to remove: ${summary.links.removed.join(', ')}`);
123
+ return parts.length ? ` ${parts.join('. ')}.` : '';
124
+ }
125
+
126
+ async function sourceRelease(ctx, publish, stageOnly = false) {
127
+ const { directory, key } = selected(ctx);
128
+ if (!ctx.options.spaceId) throw usageError('Pass --space-id <id> for the existing Space.');
129
+ const intentKey = publish ? requestId(ctx) : null;
130
+ const release = await prepareSpaceRelease(directory, key);
131
+ try {
132
+ const identity = [ctx.runtime.apiBase.replace(/\/$/, ''), ctx.runtime.profileName, key, ctx.options.spaceId, intentKey];
133
+ let verification = publish && !ctx.options.dryRun ? spacePublicationIntent(directory, identity) : null;
134
+ const local = intentChanges(directory, ctx.options.spaceId, release.manifest,
135
+ localSpaceChanges(directory, ctx.options.spaceId, release.manifest), verification);
136
+ const body = { space_id: ctx.options.spaceId, manifest: release.manifest, source_files: release.sourceFiles,
137
+ artifact_files: release.files, reuse_grants: object(ctx.options.reuseGrants || '{}', '--reuse-grants'),
138
+ ...(ctx.options.reuseGrantsOnly ? { authorization_mode: 'reuse_only' } : {}),
139
+ ...(local.changes ? { changes: local.changes } : {}) };
140
+ const sourceDigest = appFilesDigest(release.sourceFiles), artifactDigest = appFilesDigest(release.files);
141
+ const call = (operation, data) => httpRequest({ runtime: { ...ctx.runtime, timeoutMs: Math.max(ctx.runtime.timeoutMs || 0, 600_000) },
142
+ method: 'POST', path: `/portal_spaces/${operation}`, body: data, spacesProtocol: 1 });
143
+ if (verification && (verification.local_changes_fingerprint ?? null) !== local.fingerprint) {
144
+ throw usageError('This request ID belongs to different Skill folders or list entries. Restore the state you first deployed to retry, or use a new request ID for a new release.');
145
+ }
146
+ if (!verification) {
147
+ verification = (await call('source-verify', body)).payload;
148
+ }
149
+ if (verification?.valid !== true || verification.space_id !== body.space_id || verification.source_digest !== sourceDigest
150
+ || verification.artifact_digest !== artifactDigest || JSON.stringify(Object.entries(verification.reuse_grants || {}).sort())
151
+ !== JSON.stringify(Object.entries(body.reuse_grants).sort())
152
+ || (verification.authorization_mode ?? 'authorize_missing') !== (body.authorization_mode ?? 'authorize_missing')) {
153
+ throw usageError('This request ID belongs to different source or grant choices. Keep the original bytes to retry, or use a new request ID for a new release.');
154
+ }
155
+ const controller = new AbortController();
156
+ const cancel = () => controller.abort();
157
+ process.once('SIGINT', cancel); process.once('SIGTERM', cancel);
158
+ let render;
159
+ try { render = await verifySpaceRelease(release, { capabilities: verification.capabilities, signal: controller.signal,
160
+ authorizationMode: body.authorization_mode, unavailableActions: verification.unavailable_actions }); }
161
+ finally { process.removeListener('SIGINT', cancel); process.removeListener('SIGTERM', cancel); }
162
+ const diagnosticPath = saveSpaceVerification(directory, { ...render, space_id: body.space_id });
163
+ if (!['passed', 'not_applicable_container'].includes(render.status)) {
164
+ throw usageError('The built Space did not pass offline render verification. Nothing was published.', { render, diagnostic_path: diagnosticPath });
165
+ }
166
+ if (publish && !ctx.options.dryRun) {
167
+ // The retained intent pins the local change set next to the server verification, with the
168
+ // lock and list it was computed from: the lock and list this publication then rewrites can
169
+ // never turn the identical command into a different intent.
170
+ verification = spacePublicationIntent(directory, identity, { ...verification, local_changes_fingerprint: local.fingerprint,
171
+ local_state: local.lock ? { lock: local.lock, resources: local.resources ?? null } : null });
172
+ }
173
+ const { local_changes_fingerprint: _fingerprint, local_state: _state, ...serverVerification } = verification;
174
+ // Close/check the pinned local snapshot before the first remote mutation.
175
+ // The upload uses the captured bytes, not mutable working-tree paths.
176
+ release.close();
177
+ const result = publish && !ctx.options.dryRun
178
+ ? (await call(stageOnly ? 'source-stage' : 'source-publish', { ...body, verification: serverVerification, request_id: intentKey })).payload
179
+ : serverVerification;
180
+ if (publish && !ctx.options.dryRun && !stageOnly) recordPublication(directory, local, result);
181
+ const data = { ...result, render, diagnostic_path: diagnosticPath, ...(local.summary ? { local_changes: local.summary } : {}) };
182
+ return ctx.output.emitSuccess({ command: ctx.spec.command_path.join(' '), data,
183
+ humanSummary: publish && !ctx.options.dryRun
184
+ ? stageOnly ? `Prepared ${key} as revision ${result.candidate_revision}. ${result.preview_url || 'This container has no executable preview.'} Live source was not changed.${describePendingChanges(local.summary).replace(/\.$/, '; they go live with notis spaces promote.')}`
185
+ : `Published ${key} as revision ${result.published_revision}${result.replayed ? ' (confirmed retry)' : ''}.${describeAppliedChanges(result)}${describeViewerReads(serverVerification.capabilities)}${describeCloudComputer(serverVerification.capabilities)}`
186
+ : `Verified ${key}'s frozen source${render.status === 'passed' ? ', rendered fixtures' : ' (container)'} and current authorizations; nothing was published.${describePendingChanges(local.summary)}${describeViewerReads(serverVerification.capabilities)}${describeCloudComputer(serverVerification.capabilities)}`,
187
+ warnings: local.warnings, meta: { mutating: publish && !ctx.options.dryRun } });
188
+ } finally { release.close(); }
189
+ }
190
+
191
+ /** Pull the Space's links as its resource list and every editable linked Skill as a folder (R5). */
192
+ async function materializeSpaceLinks(ctx, { projectDir, spaceId, sourceRevision }) {
193
+ const sourceSkillFolders = collectSkillFolders(projectDir);
194
+ const query = new URLSearchParams({ space_id: spaceId });
195
+ const links = (await httpRequest({ runtime: ctx.runtime, method: 'GET', path: `/portal_spaces/space-links?${query}`, spacesProtocol: 1 })).payload?.links;
196
+ const list = Array.isArray(links) ? links : [];
197
+ const skills = {}, skipped = [];
198
+ for (const link of list.filter((entry) => entry?.kind === 'skill')) {
199
+ if (link.available === false) { skipped.push({ alias: link.alias, reason: 'unavailable' }); continue; }
200
+ if (!isFolderAlias(link.alias)) {
201
+ // A staged `skill:<id>` alias cannot name a folder: rename the link first to edit the Skill here.
202
+ skipped.push({ alias: link.alias, reason: `rename with notis spaces resources name ${spaceId} ${link.binding_id}` }); continue;
203
+ }
204
+ let read;
205
+ try {
206
+ read = (await httpRequest({ runtime: ctx.runtime, method: 'POST', path: '/portal_skills/native-authoring', spacesProtocol: 1,
207
+ body: { operation: 'read', target: { space_id: spaceId, binding_id: link.binding_id }, include_files: true } })).payload;
208
+ } catch (error) {
209
+ // A curated or otherwise read-only Skill stays listed without a folder; the deploy never edits it.
210
+ if (error instanceof CliError && ['forbidden', 'conflict', 'usage_error'].includes(error.code)) {
211
+ skipped.push({ alias: link.alias, reason: 'read-only' }); continue;
212
+ }
213
+ throw error;
214
+ }
215
+ const files = read?.definition?.files;
216
+ if (!files || typeof files !== 'object' || typeof files['SKILL.md'] !== 'string' || read.skill_id !== link.resource_id
217
+ || read.target?.binding_id !== link.binding_id || !read.version) {
218
+ throw usageError(`The server did not return the verified files of Skill ${link.alias}.`);
219
+ }
220
+ // Hash exactly what the folder keeps, so an untouched folder never reads as an edit (bytecode caches are not kept).
221
+ const kept = skillFolderFiles(files);
222
+ writeSkillFolder(projectDir, link.alias, kept, { sourceSnapshot: sourceSkillFolders[link.alias]?.files });
223
+ skills[link.alias] = { skill_id: read.skill_id, binding_id: read.target.binding_id, binding_revision: read.target.binding_revision,
224
+ version: read.version, folder_hash: appFilesDigest(kept) };
225
+ }
226
+ writeResourceList(projectDir, resourceEntries(list));
227
+ writeSpaceLock(projectDir, lockAfterPull({ spaceId, apiBase: ctx.runtime.apiBase.replace(/\/$/, ''), sourceRevision, links: list, skills }));
228
+ return { links: resourceEntries(list), skills: Object.keys(skills).sort(), skipped };
229
+ }
230
+
231
+ const idArgument = { token: '<space-id>', key: 'spaceId', description: 'Exact existing Space ID.' };
232
+ const recordOption = { flags: '--record <record-key>', description: 'Fixed record context, when this is a record-scoped Space.' };
233
+ const requestOption = { flags: '--request-id <key>', description: 'Stable intent key. Reuse after timeouts or lost replies.' };
234
+
235
+ export const spacesCommandSpecs = [
236
+ viewRenderCommandSpec({ screenshotOnly: true }),
237
+ {
238
+ command_path: ['spaces', 'init'], summary: 'Scaffold an editable collection view with first-party record components.',
239
+ when_to_use: 'Start a local Space collection source for an existing linked database. Does not create or deploy remote resources.',
240
+ args_schema: { arguments: [{ token: '<name>', key: 'name', description: 'Space display name.' },
241
+ { token: '[dir]', key: 'dir', description: 'New or empty local source directory.' }], options: [
242
+ { flags: '--database-key <key>', description: 'Portable key of the database this Space links.' },
243
+ { flags: '--path <path>', description: 'Descriptive view path; defaults to records.' },
244
+ { flags: '--title-property <id>', description: 'Canonical title/number property ID, or the title column (default).' },
245
+ ] },
246
+ examples: ['notis spaces init Notes ./notes --database-key notes --path notes'],
247
+ mutates: true, idempotent: false, require_auth: false, backend_call: { type: 'local', name: 'scaffold_space_collection' },
248
+ async handler(ctx) {
249
+ const result = await scaffoldSpaceCollection({ projectDir: resolveProjectDir(ctx.args.dir || ctx.args.name), name: ctx.args.name,
250
+ databaseKey: ctx.options.databaseKey, path: ctx.options.path, titleProperty: ctx.options.titleProperty });
251
+ return ctx.output.emitSuccess({ command: 'spaces init', data: result,
252
+ humanSummary: `Collection source created in ${result.projectDir}. Build with --space collection after linking the declared database.` });
253
+ },
254
+ },
255
+ {
256
+ command_path: ['spaces', 'navigation', 'bind'],
257
+ summary: 'Bind a named destination for the next source publication without granting target access.',
258
+ when_to_use: 'Connect a portable navigation alias to an existing Space or exact record before building and publishing.',
259
+ args_schema: { arguments: [idArgument], options: [
260
+ { flags: '--alias <name>', description: 'Declared portable navigation alias.' },
261
+ { flags: '--target <json>', description: 'Exact {space_id,document_id?}, or null to remove the draft binding.' },
262
+ { flags: '--revision <number>', description: 'Current source Space metadata revision.' },
263
+ { flags: '--dry-run', description: 'Check current source/target access and revision without changing anything.' },
264
+ ] },
265
+ examples: ['notis spaces navigation bind <space-id> --alias history --target \'{"space_id":"<history-id>"}\' --revision 2 --dry-run'],
266
+ mutates: true, idempotent: true, require_auth: true,
267
+ backend_call: { type: 'http', path: '/portal_spaces/navigation-binding' },
268
+ handler(ctx) {
269
+ const expected = ctx.options.revision;
270
+ if (typeof expected !== 'string' || !/^\d+$/.test(expected) || !Number.isSafeInteger(Number(expected))) throw usageError('Pass the current metadata --revision.');
271
+ if (typeof ctx.options.alias !== 'string' || !/^[a-z][a-z0-9_-]{0,99}$/.test(ctx.options.alias)) throw usageError('Pass a stable lowercase --alias.');
272
+ const target = ctx.options.target === 'null' ? null : object(ctx.options.target, '--target');
273
+ return remote(ctx, 'navigation-binding', { body: { space_id: ctx.args.spaceId, alias: ctx.options.alias,
274
+ target, revision: Number(expected), dry_run: Boolean(ctx.options.dryRun) } });
275
+ },
276
+ },
277
+ // Linking a database, Skill or automation into a Space is the resource's own
278
+ // operation (`notis skills links`, LOCAL_NOTIS_UPDATE_AUTOMATION links,
279
+ // LOCAL_NOTIS_DATABASE_UPSERT_DATABASE links); there is no Space-side include.
280
+ {
281
+ command_path: ['spaces','resources','name'],
282
+ summary: 'Give an included resource a portable alias before the first source revision.',
283
+ when_to_use: 'Prepare staged resources for source authoring without replacing their IDs, scope or owners.',
284
+ args_schema: { arguments: [idArgument,{token:'<binding-id>',key:'bindingId',description:'Exact included binding ID from spaces get.'}], options:[
285
+ {flags:'--alias <name>',description:'Stable lowercase alias to use in source.'},
286
+ {flags:'--revision <number>',description:'Current Space metadata revision.'},
287
+ {flags:'--dry-run',description:'Check naming, current access and revision without changing metadata.'},
288
+ ]},
289
+ examples:['notis spaces resources name <space-id> <binding-id> --alias weekly-report --revision 0 --dry-run'],
290
+ mutates:true,idempotent:true,require_auth:true,
291
+ backend_call:{type:'http',path:'/portal_spaces/resource-name'},
292
+ handler(ctx) {
293
+ const expected=ctx.options.revision;
294
+ if(typeof expected!=='string'||!/^\d+$/.test(expected)||!Number.isSafeInteger(Number(expected))) throw usageError('Pass the current --revision from spaces get.');
295
+ if(typeof ctx.options.alias!=='string'||!/^[a-z][a-z0-9_-]{0,99}$/.test(ctx.options.alias)) throw usageError('Pass a stable lowercase --alias.');
296
+ return remote(ctx,'resource-name',{body:{space_id:ctx.args.spaceId,binding_id:ctx.args.bindingId,
297
+ alias:ctx.options.alias,revision:Number(expected),dry_run:Boolean(ctx.options.dryRun)}});
298
+ },
299
+ },
300
+ {
301
+ command_path: ['spaces', 'pull'], summary: 'Pull one editable Space source snapshot, its resource list and its Skill folders into an empty directory.',
302
+ when_to_use: `Edit a Space you can administer: the source, its links (${RESOURCES_FILE}) and every editable linked Skill (${SKILLS_DIR}/<alias>/) arrive together, with a local lock file so a later deploy sends only what you changed.`,
303
+ args_schema: { arguments: [idArgument, { token: '[dir]', key: 'dir', description: 'New or empty destination directory (defaults to the Space ID).' }],
304
+ options: [{ flags: '--revision <number>', description: 'Exact published source revision; defaults to the current one.' }] },
305
+ examples: ['notis spaces pull <space-id> ./space-source'], mutates: true, idempotent: true, require_auth: true,
306
+ backend_call: { type: 'http', path: '/portal_spaces/source' },
307
+ async handler(ctx) {
308
+ const requestedRevision = ctx.options.revision === undefined ? null : revision(ctx);
309
+ const sourceLink = { space_id: ctx.args.spaceId, api_base: ctx.runtime.apiBase.replace(/\/$/, '') };
310
+ let databaseStructures, authoringOverrides;
311
+ const result = await pullAppSource({ targetDir: resolve(ctx.args.dir || ctx.args.spaceId), force: false,
312
+ sourceKind: 'space', sourceLink, version: requestedRevision || 'latest',
313
+ async downloadSource() {
314
+ const query = new URLSearchParams({ space_id: ctx.args.spaceId, format: 'archive',
315
+ ...(requestedRevision === null ? {} : { revision: String(requestedRevision) }) });
316
+ const source = (await httpRequest({ runtime: ctx.runtime, method: 'GET',
317
+ path: `/portal_spaces/source?${query}`, spacesProtocol: 1 })).payload;
318
+ if (source?.space_id !== ctx.args.spaceId || source.archive_format !== 'tar-gzip-v1'
319
+ || typeof source.source_archive !== 'string' || !/^[a-f0-9]{64}$/.test(source.source_digest || '')) {
320
+ throw usageError('The server did not return a verified Space source snapshot.');
321
+ }
322
+ const archiveBuffer = Buffer.from(source.source_archive, 'base64');
323
+ if (archiveBuffer.toString('base64') !== source.source_archive
324
+ || createHash('sha256').update(archiveBuffer).digest('hex') !== source.archive_sha256) {
325
+ throw usageError('The downloaded Space source bytes do not match their fingerprint.');
326
+ }
327
+ sourceLink.source_digest = source.source_digest;
328
+ sourceLink.local_key = source.manifest?.local_key;
329
+ databaseStructures = source.database_structures;
330
+ authoringOverrides = source.authoring_overrides;
331
+ return { archiveBuffer, revision: source.revision };
332
+ } });
333
+ const authoringFiles = refreshSpaceAuthoring(result.projectDir, sourceLink.local_key, { databaseStructures, authoringOverrides });
334
+ const materialized = await materializeSpaceLinks(ctx, { projectDir: result.projectDir, spaceId: ctx.args.spaceId, sourceRevision: result.version });
335
+ const skipped = materialized.skipped.map((entry) => `${entry.alias} (${entry.reason})`);
336
+ return ctx.output.emitSuccess({ command: 'spaces pull', data: { ...result, space_id: ctx.args.spaceId, ...materialized, authoring_projection_files: authoringFiles },
337
+ humanSummary: `Pulled source revision ${result.version} into ${result.projectDir}: ${materialized.links.length} linked resources in ${RESOURCES_FILE}, `
338
+ + `${materialized.skills.length} Skill folders under ${SKILLS_DIR}/.${skipped.length ? ` Listed without a folder: ${skipped.join(', ')}.` : ''}`,
339
+ warnings: skipped.length ? [`Skills listed without an editable folder: ${skipped.join(', ')}.`] : [] });
340
+ },
341
+ },
342
+ ...['verify', 'preview', 'deploy'].map(operation => ({
343
+ command_path: ['spaces', operation],
344
+ summary: operation === 'verify' ? 'Render one frozen Space with offline fixtures and validate its current action authorizations.'
345
+ : operation === 'preview' ? 'Seal a checked unpublished revision of the same Space and return its authenticated preview link.'
346
+ : 'Publish one built Space with a retry-safe, independent source revision.',
347
+ when_to_use: operation === 'verify' ? 'Check immutable build bytes and authoring compatibility without executing actions or publishing.'
348
+ : operation === 'preview' ? 'Review a checked candidate using current Editor access without changing live source or cloning the Space.'
349
+ : 'Update only the selected existing Space; this does not publish to the Store or change siblings.',
350
+ args_schema: { arguments: [{ token: '[dir]', key: 'dir', description: 'Source workspace (default current directory).' }],
351
+ options: [{ flags: '--space <key>', description: 'Exact source key from the workspace index.' },
352
+ { flags: '--space-id <id>', description: 'Exact existing destination Space.' },
353
+ { flags: '--reuse-grants <json>', description: 'Explicit action-key to saved-grant-ID map. Otherwise your own connections are used.' },
354
+ { flags: '--reuse-grants-only', description: 'Never authorize new actions. Reuse only the supplied saved grants; other declarations remain unavailable. Requires existing bound databases.' },
355
+ ...(operation !== 'verify' ? [requestOption, { flags: '--dry-run', description: 'Validate without uploading or publishing.' }] : [])] },
356
+ examples: [`notis spaces ${operation} . --space overview --space-id <space-id>${operation !== 'verify' ? ' --request-id release-1' : ''}`],
357
+ mutates: operation !== 'verify', idempotent: true, require_auth: true,
358
+ backend_call: { type: 'http', path: operation === 'deploy' ? '/portal_spaces/source-publish'
359
+ : operation === 'preview' ? '/portal_spaces/source-stage' : '/portal_spaces/source-verify' },
360
+ handler: ctx => sourceRelease(ctx, operation !== 'verify', operation === 'preview'),
361
+ })),
362
+ ...['promote', 'abandon'].map(operation => ({
363
+ command_path: ['spaces', operation],
364
+ summary: operation === 'promote' ? 'Publish the exact sealed Space revision, with its pending Skill and link changes, after current permission and concurrency checks.'
365
+ : 'Close an unpublished Space preview without changing its live source, Skills or links, or deleting action history.',
366
+ when_to_use: operation === 'promote' ? 'Finish a reviewed preview without rebuilding or substituting a different candidate. The Skill edits, new Skills and list changes the preview carried go live in the same transaction.'
367
+ : 'Stop using a candidate while preserving live source and completed effects.',
368
+ args_schema: { arguments: [{ token: '<release-id>', key: 'releaseId', description: 'Exact immutable release ID returned by spaces preview.' },
369
+ ...(operation === 'promote' ? [{ token: '[dir]', key: 'dir', description: 'The pulled workspace of this Space; its lock file and resources.json then record what went live.' }] : [])] },
370
+ examples: [`notis spaces ${operation} <release-id>`], mutates: true, idempotent: true, require_auth: true,
371
+ backend_call: { type: 'http', path: `/portal_spaces/source-${operation}` },
372
+ async handler(ctx) {
373
+ const result = await httpRequest({ runtime: ctx.runtime, method: 'POST', path: `/portal_spaces/source-${operation}`,
374
+ body: { release_id: ctx.args.releaseId }, spacesProtocol: 1 });
375
+ if (operation === 'promote' && ctx.args.dir) {
376
+ const directory = resolveProjectDir(ctx.args.dir);
377
+ const lock = readSpaceLock(directory);
378
+ if (lock && result.payload?.space_id && lock.space_id !== result.payload.space_id) {
379
+ throw usageError(`${directory} was pulled from Space ${lock.space_id}, not from the promoted Space.`);
380
+ }
381
+ if (lock) recordPublication(directory, { lock, folders: collectSkillFolders(directory) }, result.payload);
382
+ }
383
+ return ctx.output.emitSuccess({ command: ctx.spec.command_path.join(' '), data: result.payload,
384
+ humanSummary: operation === 'promote' && result.payload?.published_revision !== undefined
385
+ ? `Published revision ${result.payload.published_revision}${result.payload.replayed ? ' (confirmed retry)' : ''}.${describeAppliedChanges(result.payload)}`
386
+ : JSON.stringify(result.payload, null, 2),
387
+ meta: { mutating: true, request_id: result.requestId } });
388
+ },
389
+ })),
390
+ {
391
+ command_path: ['spaces', 'list'], summary: 'List Spaces currently available to your account.',
392
+ when_to_use: 'Discover accessible Spaces without exposing an issuer-wide resource catalogue.',
393
+ args_schema: {}, examples: ['notis spaces list'], mutates: false, idempotent: true, require_auth: true,
394
+ backend_call: { type: 'http', path: '/portal_spaces/list' },
395
+ handler: ctx => remote(ctx, 'list', { method: 'GET' }),
396
+ },
397
+ {
398
+ command_path: ['spaces', 'get'], summary: 'Read one accessible Space and its scoped resources.',
399
+ when_to_use: 'Inspect the current published revision and named capabilities before executing.',
400
+ args_schema: { arguments: [idArgument], options: [recordOption] },
401
+ examples: ['notis spaces get <space-id>'], mutates: false, idempotent: true, require_auth: true,
402
+ backend_call: { type: 'http', path: '/portal_spaces/get' },
403
+ handler: ctx => remote(ctx, 'get', { method: 'GET', query: { space_id: ctx.args.spaceId, document_id: ctx.options.record } }),
404
+ },
405
+ {
406
+ command_path: ['spaces', 'action'], summary: 'Execute a declared action using its saved authorization.',
407
+ when_to_use: 'Use Space capabilities as the actual actor, with the saved issuer connection and payer.',
408
+ args_schema: { arguments: [idArgument, { token: '<action-id>', key: 'actionId', description: 'Declared action key.' }],
409
+ options: [{ flags: '--revision <number>', description: 'Exact published source revision.' },
410
+ { flags: '--preview-release <id>', description: 'Exact sealed candidate ID; requires current Editor access.' },
411
+ { flags: '--schema-revision <number>', description: 'Native schema revision; required for native row writes.' },
412
+ { flags: '--inputs <json>', description: 'Declared action input values; defaults to {}.' }, recordOption, requestOption,
413
+ { flags: '--dry-run', description: 'Validate current access and action inputs without executing or billing.' }] },
414
+ examples: ['notis spaces action <space-id> refresh --revision 1 --inputs \'{}\' --request-id refresh-1 --dry-run'],
415
+ mutates: true, idempotent: true, require_auth: true,
416
+ backend_call: { type: 'http', path: '/portal_spaces/action' },
417
+ handler: ctx => remote(ctx, 'action', { body: { space_id: ctx.args.spaceId, action_id: ctx.args.actionId,
418
+ revision: revision(ctx), inputs: object(ctx.options.inputs || '{}', '--inputs'), request_id: requestId(ctx),
419
+ ...(ctx.options.previewRelease ? { preview_release_id: ctx.options.previewRelease } : {}),
420
+ document_id: ctx.options.record, schema_revision: schemaRevision(ctx), dry_run: Boolean(ctx.options.dryRun) } }),
421
+ },
422
+ {
423
+ command_path: ['spaces', 'viewer-read'], summary: 'Run one declared viewer read as yourself.',
424
+ when_to_use: 'Check what an explorer Space shows you: the databases or Skills you can open, with your own access and never an issuer grant.',
425
+ args_schema: { arguments: [idArgument, { token: '<operation>', key: 'operation',
426
+ description: 'list_databases, get_database, query_database, list_skills or get_skill.' }],
427
+ options: [{ flags: '--revision <number>', description: 'Exact published source revision.' },
428
+ { flags: '--preview-release <id>', description: 'Exact sealed candidate ID; requires current Editor access.' },
429
+ { flags: '--input <json>', description: 'Read input, e.g. {"database_id":"<id>"}; defaults to {}.' }, recordOption] },
430
+ examples: ['notis spaces viewer-read <space-id> list_databases --revision 3',
431
+ 'notis spaces viewer-read <space-id> query_database --revision 3 --input \'{"database_id":"<id>","request":{"page_size":20}}\''],
432
+ mutates: false, idempotent: true, require_auth: true,
433
+ backend_call: { type: 'http', path: '/portal_spaces/viewer-read' },
434
+ handler: ctx => remote(ctx, 'viewer-read', { body: { space_id: ctx.args.spaceId, operation: ctx.args.operation,
435
+ revision: revision(ctx), input: object(ctx.options.input || '{}', '--input'), document_id: ctx.options.record,
436
+ ...(ctx.options.previewRelease ? { preview_release_id: ctx.options.previewRelease } : {}) } }),
437
+ },
438
+ {
439
+ command_path: ['spaces', 'grants', 'list'], summary: 'Inspect authorization records for a Space you can edit.',
440
+ when_to_use: 'Inspect issuers and exact constraints without exposing connection secrets.',
441
+ args_schema: { arguments: [idArgument], options: [recordOption] }, examples: ['notis spaces grants list <space-id>'],
442
+ mutates: false, idempotent: true, require_auth: true, backend_call: { type: 'http', path: '/portal_spaces/grants' },
443
+ handler: ctx => remote(ctx, 'grants', { method: 'GET', query: { space_id: ctx.args.spaceId, document_id: ctx.options.record } }),
444
+ },
445
+ {
446
+ command_path: ['spaces', 'grants', 'create'], summary: 'Authorize an exact template using your current connection.',
447
+ when_to_use: 'Create a Space-bound authorization; the server resolves the signed-in issuer connection.',
448
+ args_schema: { arguments: [idArgument], options: [{ flags: '--template <file>', description: 'Canonical action template JSON file.' },
449
+ recordOption, requestOption, { flags: '--dry-run', description: 'Validate the template and Editor access without creating a grant.' }] },
450
+ examples: ['notis spaces grants create <space-id> --template action.json --request-id grant-1 --dry-run'],
451
+ mutates: true, idempotent: true, require_auth: true, backend_call: { type: 'http', path: '/portal_spaces/grant-authorize' },
452
+ handler(ctx) {
453
+ if (!ctx.options.template) throw usageError('Pass --template <file> with the canonical action template.');
454
+ return remote(ctx, 'grant-authorize', { body: { space_id: ctx.args.spaceId, request_id: requestId(ctx),
455
+ template: object(readFileSync(resolve(ctx.options.template), 'utf8'), '--template'),
456
+ document_id: ctx.options.record, dry_run: Boolean(ctx.options.dryRun) } });
457
+ },
458
+ },
459
+ {
460
+ command_path: ['spaces', 'grants', 'revoke'], summary: 'Revoke one saved Space authorization.',
461
+ when_to_use: 'Stop future dispatches for an authorization you issued or can administer.',
462
+ args_schema: { arguments: [{ token: '<grant-id>', key: 'grantId', description: 'Exact grant ID from grants list.' }] },
463
+ examples: ['notis spaces grants revoke <grant-id>'], mutates: true, idempotent: true, require_auth: true,
464
+ backend_call: { type: 'http', path: '/portal_spaces/grant-revoke' },
465
+ handler: ctx => remote(ctx, 'grant-revoke', { body: { grant_id: ctx.args.grantId } }),
466
+ },
467
+ {
468
+ command_path: ['spaces', 'store', 'list'], summary: 'List Space Store listings you can see.',
469
+ when_to_use: 'Find Space templates published to your team or approved for everyone, and your own listings.',
470
+ args_schema: { options: [{ flags: '--channel <team|public>', description: 'Only one channel.' }] },
471
+ examples: ['notis spaces store list --channel team'], mutates: false, idempotent: true, require_auth: true,
472
+ backend_call: { type: 'http', path: '/portal_spaces/store-listings' },
473
+ handler: ctx => remote(ctx, 'store-listings', { method: 'GET', query: { channel: ctx.options.channel } }),
474
+ },
475
+ {
476
+ command_path: ['spaces', 'store', 'get'], summary: 'Read one Store listing with its versions and your installs.',
477
+ when_to_use: 'Check what a listing contains, its review state and whether your copies have an update.',
478
+ args_schema: { arguments: [{ token: '<listing-id>', key: 'listingId', description: 'Exact listing ID.' }] },
479
+ examples: ['notis spaces store get <listing-id>'], mutates: false, idempotent: true, require_auth: true,
480
+ backend_call: { type: 'http', path: '/portal_spaces/store-listing' },
481
+ handler: ctx => remote(ctx, 'store-listing', { method: 'GET', query: { listing_id: ctx.args.listingId } }),
482
+ },
483
+ {
484
+ command_path: ['spaces', 'store', 'installs'], summary: 'List the Spaces you installed from the Store.',
485
+ when_to_use: 'See which installed copies have an update or conflicts to resolve.',
486
+ args_schema: {}, examples: ['notis spaces store installs'], mutates: false, idempotent: true, require_auth: true,
487
+ backend_call: { type: 'http', path: '/portal_spaces/store-installs' },
488
+ handler: ctx => remote(ctx, 'store-installs', { method: 'GET' }),
489
+ },
490
+ {
491
+ command_path: ['spaces', 'store', 'publish'], summary: 'Publish a Space and its sub-Spaces as the next Store version.',
492
+ when_to_use: 'Share a Space as an installable template: team listings publish at once, public ones wait for review.',
493
+ args_schema: { arguments: [idArgument], options: [{ flags: '--channel <team|public>', description: 'Where to publish.' },
494
+ { flags: '--metadata <json>', description: 'Listing name, tagline, description, category, icon, accent.' },
495
+ { flags: '--starter <json>', description: 'Starter row IDs per database ({"<database-id>":["<record-key>"]}).' },
496
+ { flags: '--notes <text>', description: 'Release notes for this version.' },
497
+ requestOption, { flags: '--dry-run', description: 'Show the package counts and review diff without publishing.' }] },
498
+ examples: ['notis spaces store publish <space-id> --channel team --request-id publish-1 --dry-run'],
499
+ mutates: true, idempotent: true, require_auth: true, backend_call: { type: 'http', path: '/portal_spaces/store-publish' },
500
+ handler(ctx) {
501
+ if (!['team', 'public'].includes(ctx.options.channel)) throw usageError('Pass --channel team or --channel public.');
502
+ return remote(ctx, 'store-publish', { body: { space_id: ctx.args.spaceId, channel: ctx.options.channel, request_id: requestId(ctx),
503
+ ...(ctx.options.metadata ? { metadata: object(ctx.options.metadata, '--metadata') } : {}),
504
+ ...(ctx.options.starter ? { starter: object(ctx.options.starter, '--starter') } : {}),
505
+ ...(ctx.options.notes ? { notes: ctx.options.notes } : {}), dry_run: Boolean(ctx.options.dryRun) } });
506
+ },
507
+ },
508
+ {
509
+ command_path: ['spaces', 'store', 'install'], summary: 'Install a Store listing as your own independent copy.',
510
+ when_to_use: 'Add a published Space template to your account; your copy keeps your changes on later updates.',
511
+ args_schema: { arguments: [{ token: '<listing-id>', key: 'listingId', description: 'Exact listing ID.' }],
512
+ options: [{ flags: '--name <name>', description: 'Name of the installed top Space.' },
513
+ { flags: '--parent <space-id>', description: 'Install under one of your Spaces.' },
514
+ requestOption, { flags: '--dry-run', description: 'Show what the copy contains without installing.' }] },
515
+ examples: ['notis spaces store install <listing-id> --request-id install-1'],
516
+ mutates: true, idempotent: true, require_auth: true, backend_call: { type: 'http', path: '/portal_spaces/store-install' },
517
+ handler: ctx => remote(ctx, 'store-install', { body: { listing_id: ctx.args.listingId, request_id: requestId(ctx),
518
+ ...(ctx.options.name ? { name: ctx.options.name } : {}), ...(ctx.options.parent ? { parent_space_id: ctx.options.parent } : {}),
519
+ dry_run: Boolean(ctx.options.dryRun) } }),
520
+ },
521
+ {
522
+ command_path: ['spaces', 'store', 'update'], summary: 'Update an installed copy, keeping your changes, or resolve its conflicts.',
523
+ when_to_use: 'Take a newer Store version: unchanged parts update, your edits stay, and parts both sides changed become conflicts you resolve.',
524
+ args_schema: { arguments: [{ token: '<install-id>', key: 'installId', description: 'Exact install ID from store installs.' }],
525
+ options: [{ flags: '--resolve <json>', description: 'Per conflict key: "keep_mine" or "take_theirs".' },
526
+ requestOption, { flags: '--dry-run', description: 'Show the three-way plan without changing anything.' }] },
527
+ examples: ['notis spaces store update <install-id> --request-id update-1 --dry-run',
528
+ 'notis spaces store update <install-id> --resolve \'{"skill:<key>":"keep_mine"}\' --request-id resolve-1'],
529
+ mutates: true, idempotent: true, require_auth: true, backend_call: { type: 'http', path: '/portal_spaces/store-update' },
530
+ handler: ctx => remote(ctx, 'store-update', { body: { install_id: ctx.args.installId, request_id: requestId(ctx),
531
+ ...(ctx.options.resolve ? { resolution: object(ctx.options.resolve, '--resolve') } : {}), dry_run: Boolean(ctx.options.dryRun) } }),
532
+ },
533
+ {
534
+ command_path: ['spaces', 'store', 'unpublish'], summary: 'Stop new installs of a Store listing.',
535
+ when_to_use: 'Withdraw a listing: existing copies keep working and a version waiting for review is withdrawn.',
536
+ args_schema: { arguments: [{ token: '<listing-id>', key: 'listingId', description: 'Exact listing ID.' }], options: [requestOption] },
537
+ examples: ['notis spaces store unpublish <listing-id> --request-id unpublish-1'],
538
+ mutates: true, idempotent: true, require_auth: true, backend_call: { type: 'http', path: '/portal_spaces/store-unpublish' },
539
+ handler: ctx => remote(ctx, 'store-unpublish', { body: { listing_id: ctx.args.listingId, request_id: requestId(ctx) } }),
540
+ },
541
+ {
542
+ command_path: ['spaces', 'build'],
543
+ summary: 'Build one independent Space and save its frozen source/artifact snapshot.',
544
+ when_to_use: 'Prepare a selected Space for verification without publishing it or changing siblings.',
545
+ args_schema: { arguments: [{ token: '[dir]', key: 'dir', description: 'Source workspace (default current directory).' }],
546
+ options: [{ flags: '--space <key>', description: 'Exact source key from the workspace Space index.' }] },
547
+ examples: ['notis spaces build . --space overview'],
548
+ mutates: true, idempotent: true, require_auth: false,
549
+ backend_call: { type: 'local', name: 'space_build' },
550
+ async handler(ctx) {
551
+ const { directory, key } = selected(ctx);
552
+ const result = await buildSpaceArtifact(directory, key, { stdio: ctx.output.isMachineMode() ? 'pipe' : 'inherit' });
553
+ return ctx.output.emitSuccess({ command: 'spaces build', data: result, humanSummary: `Built ${key}; no Space was published.` });
554
+ },
555
+ },
556
+ {
557
+ command_path: ['spaces', 'inspect'],
558
+ summary: 'Inspect one local Space definition and its declared capabilities.',
559
+ when_to_use: 'Read the selected source definition without loading sibling definitions or account resources.',
560
+ args_schema: { arguments: [{ token: '[dir]', key: 'dir', description: 'Source workspace (default current directory).' }],
561
+ options: [{ flags: '--space <key>', description: 'Exact source key from the workspace Space index.' }] },
562
+ examples: ['notis spaces inspect . --space overview'],
563
+ mutates: false, idempotent: true, require_auth: false,
564
+ backend_call: { type: 'local', name: 'space_source_inspect' },
565
+ async handler(ctx) {
566
+ const { directory, key } = selected(ctx);
567
+ const selection = await loadSelectedSpace(directory, key);
568
+ return ctx.output.emitSuccess({ command: 'spaces inspect', data: { key, definition: selection.definition, definition_path: selection.definitionPath },
569
+ humanSummary: `${selection.definition.name} (${key})` });
570
+ },
571
+ },
572
+ ];