@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
@@ -94,9 +94,13 @@ through the connection and show them the actual result: today's calendar events,
94
94
  their most recent email threads, the databases in their Notion. A connected
95
95
  integration is a claim; their own data on screen is proof.
96
96
 
97
- If they connect nothing, fall back to installing a public app:
98
- `LOCAL_NOTIS_LIST_PUBLIC_APP_STORE`, then `LOCAL_NOTIS_INSTALL_APP` with
99
- `destination_type="personal"`.
97
+ If they connect nothing, you may offer a Space from the Space Store instead, but
98
+ only on an account that has the Store: it is not open to every account yet. Check
99
+ with `notis spaces store list` first. If it answers that Spaces or the Store are
100
+ not available for this account (`spaces_unavailable` or
101
+ `spaces_store_unavailable`), skip this fallback without retrying and go to
102
+ step 3. Otherwise install one with
103
+ `notis spaces store install <listing-id> --request-id <stable-id>`.
100
104
 
101
105
  ## 3. Finish onboarding
102
106
 
@@ -1257,22 +1257,15 @@ async function appsDeployHandler(ctx) {
1257
1257
  try { release.close(); }
1258
1258
  catch { warnings.push('The app was updated, but the temporary release directory needs local cleanup.'); }
1259
1259
 
1260
- // One review report per skill this release changed; share the link when the user should review it.
1261
- const skillReviews = (Array.isArray(result.payload.skill_reviews) ? result.payload.skill_reviews : [])
1262
- .filter((review) => review && typeof review.review_url === 'string');
1263
1260
  return ctx.output.emitSuccess({
1264
1261
  command: ctx.spec.command_path.join(' '),
1265
1262
  data: {
1266
1263
  app_id: appId,
1267
1264
  version: deployedVersion,
1268
1265
  idempotency_key: idempotencyKey,
1269
- ...(skillReviews.length ? { skill_reviews: skillReviews } : {}),
1270
1266
  },
1271
1267
  warnings,
1272
- humanSummary: [
1273
- `Deployed to app ${appId} (version ${deployedVersion})`,
1274
- ...skillReviews.map((review) => `Review ${review.skill} changes: ${review.review_url}`),
1275
- ].join('\n'),
1268
+ humanSummary: `Deployed to app ${appId} (version ${deployedVersion})`,
1276
1269
  meta: { mutating: true, idempotency_key: idempotencyKey },
1277
1270
  });
1278
1271
  } finally {
@@ -0,0 +1,29 @@
1
+ import { uploadNativeRecordFileWithCliRuntime } from '../runtime/native-record-files.js';
2
+ import { parseJson } from './helpers.js';
3
+ import { usageError } from '../runtime/errors.js';
4
+
5
+ export const filesCommandSpecs = [{
6
+ command_path: ['files', 'upload'],
7
+ summary: 'Upload a local file for one native record you can edit.',
8
+ when_to_use: 'Save file bytes without a cloud computer; attach the returned file reference through a normal revision-checked row update.',
9
+ args_schema: { arguments: [{ token: '<file>', key: 'file', description: 'Local regular file up to 8 MB.' }], options: [
10
+ { flags: '--target <json>', description: 'Exact native target returned by schema discovery.' },
11
+ { flags: '--record-key <id>', description: 'Stable record key to attach this file to.' },
12
+ { flags: '--schema-revision <number>', description: 'Current native schema revision.' },
13
+ { flags: '--request-id <uuid>', description: 'Stable UUID; reuse with identical bytes after an uncertain response.' },
14
+ { flags: '--name <name>', description: 'Attachment name; defaults to the local basename.' },
15
+ { flags: '--content-type <mime>', description: 'File MIME type; inferred from known extensions.' },
16
+ ] },
17
+ examples: ['notis files upload ./report.html --target \'{"database_id":"<database-id>"}\' --record-key <record-key> --schema-revision 1 --request-id <uuid>'],
18
+ mutates: true, idempotent: true, require_auth: true,
19
+ backend_call: { type: 'http', path: '/portal_native_files/upload' },
20
+ async handler(ctx) {
21
+ if (!ctx.options.target || ctx.options.schemaRevision === undefined) throw usageError('Pass the discovered native --target and --schema-revision.');
22
+ const result = await uploadNativeRecordFileWithCliRuntime({ runtime: ctx.runtime, filePath: ctx.args.file,
23
+ target: parseJson(ctx.options.target, '--target'), recordKey: ctx.options.recordKey,
24
+ schemaRevision: Number(ctx.options.schemaRevision), requestId: ctx.options.requestId,
25
+ ...(ctx.options.name ? { name: ctx.options.name } : {}), ...(ctx.options.contentType ? { contentType: ctx.options.contentType } : {}) });
26
+ return ctx.output.emitSuccess({ command: 'files upload', data: result,
27
+ humanSummary: 'Uploaded and verified the file. Attach the returned file reference with a revision-checked record update.', meta: { mutating: true } });
28
+ },
29
+ }];
@@ -2,9 +2,10 @@
2
2
  * `notis handover` -- give the branch you are on to a Notis agent.
3
3
  *
4
4
  * The coding agent already sitting in the user's terminal is the caller here.
5
- * It pushes the branch, then hands the task to whichever agent the user picked:
6
- * the hosted Notis agent, or their own Codex/Claude Code running in the Notis
7
- * cloud sandbox or on their Mac.
5
+ * It pushes the branch, then hands the task to Notis. By default (`auto`) Notis
6
+ * picks the agent itself; `--route notis|codex|claude` names one instead, and
7
+ * the server turns that into a plain sentence in the task ("Use Codex for this
8
+ * task."), so the user's choice wins like it does in a chat message.
8
9
  *
9
10
  * The CLI deliberately does not create the cloud workspace itself. Doing so
10
11
  * would hard-code the Conductor app's database schema and script paths into the
@@ -28,7 +29,18 @@ import { fetchToolSchema, nextIdempotencyKey, runToolCommand } from './helpers.j
28
29
  const HAND_OVER_TOOL = 'LOCAL_NOTIS_HAND_OVER';
29
30
  const SEARCH_THREADS_TOOL = 'LOCAL_NOTIS_SEARCH_CODING_AGENT_THREADS';
30
31
 
31
- const ROUTES = ['notis', 'auto', 'codex_cloud', 'claude_cloud', 'codex_local', 'claude_local'];
32
+ // What --route accepts and advertises. `auto` lets Notis pick the agent.
33
+ const ROUTES = ['auto', 'notis', 'codex', 'claude'];
34
+ // Values from before Notis picked the agent itself. Still accepted, so scripts
35
+ // and agents that learned them keep working, but never shown in help, examples
36
+ // or errors. They are sent as typed; the server maps them to codex or claude.
37
+ const LEGACY_ROUTES = new Map([
38
+ ['codex_cloud', 'codex'],
39
+ ['codex_local', 'codex'],
40
+ ['claude_cloud', 'claude'],
41
+ ['claude_local', 'claude'],
42
+ ]);
43
+ const ROUTE_LABELS = { notis: 'Notis', codex: 'Codex', claude: 'Claude' };
32
44
  const BRANCH_MODES = ['new', 'same'];
33
45
 
34
46
  // A cloud coding agent takes minutes to start, and the hand-over POST itself
@@ -44,6 +56,17 @@ const HANDOVER_TIMEOUT_MS = 120_000;
44
56
  */
45
57
  const HANDOVER_UNAVAILABLE_CODES = new Set(['usage_error', 'forbidden']);
46
58
 
59
+ /** A legacy route as the agent it now means; anything else unchanged. */
60
+ function normalizeRoute(route) {
61
+ return LEGACY_ROUTES.get(route) || route;
62
+ }
63
+
64
+ /** Who the hand-over went to, in words, for progress lines and the summary. */
65
+ function describeRoute(route) {
66
+ if (route === 'auto') return 'Notis, which picks the agent';
67
+ return ROUTE_LABELS[route] || route;
68
+ }
69
+
47
70
  /**
48
71
  * Quote a value for the command line the receiving agent is told to run.
49
72
  *
@@ -121,8 +144,8 @@ async function handoverStartHandler(ctx) {
121
144
  throw usageError(`--branch-mode must be one of: ${BRANCH_MODES.join(', ')}`, { branchMode });
122
145
  }
123
146
 
124
- const route = ctx.options.route || 'notis';
125
- if (!ROUTES.includes(route)) {
147
+ const route = ctx.options.route || 'auto';
148
+ if (!ROUTES.includes(route) && !LEGACY_ROUTES.has(route)) {
126
149
  throw usageError(`--route must be one of: ${ROUTES.join(', ')}`, { route });
127
150
  }
128
151
 
@@ -223,7 +246,10 @@ async function handoverStartHandler(ctx) {
223
246
  repoSlug: ctx.options.repo || null,
224
247
  });
225
248
 
226
- ctx.output.emitProgress({ phase: 'handover', message: `Handing over to ${route}` });
249
+ ctx.output.emitProgress({
250
+ phase: 'handover',
251
+ message: `Handing over to ${describeRoute(normalizeRoute(route))}`,
252
+ });
227
253
  const result = await runToolCommand({
228
254
  runtime: {
229
255
  ...ctx.runtime,
@@ -255,7 +281,9 @@ async function handoverStartHandler(ctx) {
255
281
  branch_mode: branchMode,
256
282
  head: repository.head,
257
283
  wip_commit: wipCommit,
258
- agent_routing: payload.agent_routing || route,
284
+ // The server answers with auto, notis, codex or claude. An older server
285
+ // may echo a legacy value back, so map it here too.
286
+ agent_routing: normalizeRoute(payload.agent_routing || route),
259
287
  interaction_id: payload.interaction_id || null,
260
288
  thread_id: payload.thread_id || null,
261
289
  };
@@ -264,7 +292,7 @@ async function handoverStartHandler(ctx) {
264
292
  command: 'handover start',
265
293
  data,
266
294
  humanSummary:
267
- `Handed ${data.branch} over to ${data.agent_routing} ` +
295
+ `Handed ${data.branch} over to ${describeRoute(data.agent_routing)} ` +
268
296
  `(${branchMode === 'same' ? 'continuing the branch' : 'new branch from it'}).`,
269
297
  hints: [
270
298
  { command: 'notis handover status', reason: 'See what the agent is doing' },
@@ -313,8 +341,8 @@ export const handoverCommandSpecs = [
313
341
  summary: 'Hand the current branch to a Notis agent and keep working.',
314
342
  when_to_use:
315
343
  'Use this when you want Notis to continue work on the branch you are on -- long refactors, ' +
316
- 'test fixing, or anything that should keep running after you close the laptop. Pick the agent ' +
317
- 'with --route.',
344
+ 'test fixing, or anything that should keep running after you close the laptop. Notis picks ' +
345
+ 'the agent unless you name one with --route.',
318
346
  args_schema: {
319
347
  arguments: [{ token: '<task>', description: 'What the agent should do, in plain language.' }],
320
348
  options: [
@@ -327,7 +355,7 @@ export const handoverCommandSpecs = [
327
355
  {
328
356
  flags: '--route <target>',
329
357
  description:
330
- 'Which agent runs it: notis (hosted, default), codex_cloud, claude_cloud, codex_local, claude_local, or auto.',
358
+ 'Which agent runs it: auto (default, Notis picks the agent), notis, codex or claude.',
331
359
  },
332
360
  { flags: '--repo <slug>', description: 'Configured repository slug on the cloud computer, when you know it.' },
333
361
  {
@@ -338,12 +366,13 @@ export const handoverCommandSpecs = [
338
366
  },
339
367
  examples: [
340
368
  'notis handover start "fix the failing auth tests"',
341
- 'notis handover start "finish the migration" --branch-mode same --route codex_cloud',
342
- 'notis handover start "add integration tests" --route claude_cloud',
343
- 'notis handover start "review and clean up this branch" --route claude_local',
369
+ 'notis handover start "finish the migration" --branch-mode same --route codex',
370
+ 'notis handover start "add integration tests" --route claude',
371
+ 'notis handover start "review and clean up this branch" --route notis',
344
372
  ],
345
373
  output_schema:
346
- 'Returns the branch, the resolved routing target, and the interaction/thread ids of the started run.',
374
+ 'Returns the branch, the agent routing (auto when Notis picks, otherwise notis, codex or claude), ' +
375
+ 'and the interaction/thread ids of the started run.',
347
376
  mutates: true,
348
377
  idempotent: false,
349
378
  require_auth: true,
@@ -12,6 +12,7 @@ const NOTIS_DATABASE_CORE_NAMES = new Set([
12
12
  'get_document',
13
13
  'list_databases',
14
14
  'upsert_database',
15
+ 'upload_file',
15
16
  'delete_database',
16
17
  ]);
17
18
 
@@ -1,4 +1,3 @@
1
- import { reportsCommandSpecs } from './reports.js';
2
1
  import { appsCommandSpecs } from './apps.js';
3
2
  import { toolsCommandSpecs } from './tools.js';
4
3
  import { metaCommandSpecs } from './meta.js';
@@ -10,14 +9,19 @@ import { profileCommandSpecs } from './profile.js';
10
9
  import { handoverCommandSpecs } from './handover.js';
11
10
  import { agentsCommandSpecs } from './agents.js';
12
11
  import { skillsCommandSpecs } from './skills.js';
12
+ import { spacesCommandSpecs } from './spaces.js';
13
+ import { viewsCommandSpecs } from './views.js';
14
+ import { filesCommandSpecs } from './files.js';
13
15
 
14
16
  export const GROUP_SUMMARIES = {
15
- reports: 'Build and save independent SDK reports into app database records.',
17
+ files: 'Upload local files for native records with verified byte receipts.',
18
+ views: 'Discover and render the views you can open.',
19
+ spaces: 'Build independent Spaces and their declared capabilities.',
16
20
  apps: 'Develop, deploy, and submit Notis Apps.',
17
21
  agents: 'Install Notis context into local coding agents.',
18
22
  handover: 'Hand the branch you are on to a Notis agent, hosted or your own Codex/Claude.',
19
23
  tools: 'Discover and execute generic tools exposed through Notis.',
20
- skills: 'Keep Notis and local-agent skills synchronized.',
24
+ skills: 'List, edit and link Notis Skills, and keep local-agent skills synchronized.',
21
25
  profile: 'Switch between signed-in accounts and their API endpoints.',
22
26
  debug: 'Inspect effective runtime context, worker identity, and trace costs.',
23
27
  smoke: 'Run deterministic connected-service smoke tests with guaranteed cleanup.',
@@ -29,8 +33,10 @@ export const COMMAND_SPECS = [
29
33
  ...onboardingCommandSpecs,
30
34
  ...agentsCommandSpecs,
31
35
  ...skillsCommandSpecs,
36
+ ...spacesCommandSpecs,
37
+ ...viewsCommandSpecs,
38
+ ...filesCommandSpecs,
32
39
  ...appsCommandSpecs,
33
- ...reportsCommandSpecs,
34
40
  ...handoverCommandSpecs,
35
41
  ...toolsCommandSpecs,
36
42
  ...diagnosticCommandSpecs,
@@ -2,6 +2,81 @@ import { getJwtSubject } from '../runtime/profiles.js';
2
2
  import { reconcileAllSkills } from '../runtime/sync-skills.js';
3
3
  import { ensureFreshOAuthCredential } from '../runtime/oauth.js';
4
4
  import { installSkillSyncService } from '../runtime/skill-sync-service.js';
5
+ import { readFileSync } from 'node:fs';
6
+ import { resolve } from 'node:path';
7
+ import { httpRequest } from '../runtime/transport.js';
8
+ import { usageError } from '../runtime/errors.js';
9
+ import { parseJson } from './helpers.js';
10
+
11
+ async function nativeSkillAuthoring(ctx, body, path = '/portal_skills/native-authoring') {
12
+ // A Space target, destination or link change touches Spaces: declare the protocol.
13
+ const spaceTarget = Boolean(body.target?.space_id || body.destination?.space_id || body.links);
14
+ const result = await httpRequest({ runtime: ctx.runtime, method: 'POST', path, body,
15
+ ...(spaceTarget ? { spacesProtocol: 1 } : {}) });
16
+ return ctx.output.emitSuccess({ command: ctx.spec.command_path.join(' '), data: result.payload,
17
+ humanSummary: JSON.stringify(result.payload, null, 2),
18
+ meta: { mutating: body.operation !== 'read' && !body.dry_run, request_id: result.requestId } });
19
+ }
20
+
21
+ function exactKeys(value, keys) {
22
+ return value && typeof value === 'object' && !Array.isArray(value)
23
+ && Object.keys(value).sort().join(',') === [...keys].sort().join(',');
24
+ }
25
+
26
+ function skillTarget(value, updating) {
27
+ const direct = updating ? ['skill_id', 'access_revision'] : ['skill_id'];
28
+ const space = updating ? ['space_id', 'binding_id', 'binding_revision', 'expected_skill_id', 'space_revision'] : ['space_id', 'binding_id'];
29
+ if (!exactKeys(value, direct) && !exactKeys(value, space)) {
30
+ throw usageError('Use one exact direct or Space target from the Skill inventory or skills read.');
31
+ }
32
+ return value;
33
+ }
34
+
35
+ function requestKey(ctx) {
36
+ const request = ctx.options.requestId || ctx.globalOptions.idempotencyKey;
37
+ if (typeof request !== 'string' || !request || request.length > 200) throw usageError('Pass --request-id <stable-key>.');
38
+ return request;
39
+ }
40
+
41
+ function linkLabel(link) {
42
+ return `${link.space_name || link.space_id}/${link.alias}${link.available === false ? ' (unavailable)' : ''}`;
43
+ }
44
+
45
+ /** One line per Skill: name, ID, kind, editability and its Space links. */
46
+ export function skillInventoryLines(page) {
47
+ const entries = Array.isArray(page?.skills) ? page.skills : [];
48
+ if (!entries.length) return 'No Skills match.';
49
+ const lines = entries.map(entry => {
50
+ const state = entry.editable ? 'editable' : `read-only${entry.read_only_reason ? ` (${entry.read_only_reason})` : ''}`;
51
+ const links = (entry.links || []).map(linkLabel);
52
+ return `${entry.name || entry.skill_id} ${entry.skill_id} ${entry.kind} ${state} links: ${links.length ? links.join(', ') : 'none'}`;
53
+ });
54
+ if (page.has_more && page.next) lines.push(`More Skills: rerun with --after ${page.next}`);
55
+ return lines.join('\n');
56
+ }
57
+
58
+ /** What a link change did and where the resource is listed now. */
59
+ export function linkChangeSummary(result) {
60
+ const parts = [];
61
+ const added = (result?.added || []).map(link => `${link.space_id}/${link.alias}`);
62
+ const removed = (result?.removed || []).map(link => `${link.space_id}/${link.alias}`);
63
+ if (added.length) parts.push(`added: ${added.join(', ')}`);
64
+ if (removed.length) parts.push(`removed: ${removed.join(', ')}`);
65
+ if (result?.became_standalone) parts.push('now your standalone Skill');
66
+ if (result?.replayed) parts.push('confirmed retry');
67
+ const current = (result?.links || []).map(linkLabel);
68
+ return `${parts.join('; ') || 'No change'}. Listed in: ${current.length ? current.join(', ') : 'none'}.`;
69
+ }
70
+
71
+ function linkEntries(value, option, required, optional = []) {
72
+ const parsed = parseJson(value, option);
73
+ const valid = entry => entry && typeof entry === 'object' && !Array.isArray(entry)
74
+ && required.every(key => key in entry) && Object.keys(entry).every(key => required.includes(key) || optional.includes(key));
75
+ if (!Array.isArray(parsed) || !parsed.length || !parsed.every(valid)) {
76
+ throw usageError(`${option} must be a JSON array of {${[...required, ...optional.map(key => `${key}?`)].join(', ')}} entries.`);
77
+ }
78
+ return parsed;
79
+ }
5
80
 
6
81
  async function loadSkillSyncEngine() {
7
82
  return import('../../dist/skill-sync/index.js');
@@ -48,6 +123,160 @@ export async function syncSkillsHandler(ctx, {
48
123
  }
49
124
 
50
125
  export const skillsCommandSpecs = [
126
+ {
127
+ command_path: ['skills', 'list'],
128
+ summary: 'List every Skill you can see, with its kind, editability and Space links.',
129
+ when_to_use: 'Find a Skill and its exact edit target before reading or editing it, or see what a Space and its sub-Spaces list. Links to Spaces you cannot open are omitted; an unavailable link gives no access.',
130
+ args_schema: { arguments: [], options: [
131
+ { flags: '--space <id>', description: 'Only Skills listed by this Space and, by default, its sub-Spaces.' },
132
+ { flags: '--no-child-spaces', description: 'With --space: direct links of that Space only.' },
133
+ { flags: '--include-disabled', description: 'Include Skills you disabled.' },
134
+ { flags: '--after <cursor>', description: 'Continue from the next cursor of the previous page.' },
135
+ { flags: '--limit <n>', description: 'Page size between 1 and 100 (default 50).' },
136
+ ] },
137
+ examples: ['notis skills list', 'notis skills list --space <space-id> --no-child-spaces --json'],
138
+ mutates: false, idempotent: true, require_auth: true,
139
+ backend_call: { type: 'http', path: '/portal_skills/inventory' },
140
+ async handler(ctx) {
141
+ const body = {};
142
+ if (ctx.options.space !== undefined) body.space_id = ctx.options.space;
143
+ if (ctx.options.childSpaces === false) {
144
+ if (body.space_id === undefined) throw usageError('--no-child-spaces needs --space <id>.');
145
+ body.include_child_spaces = false;
146
+ }
147
+ if (ctx.options.includeDisabled) body.include_disabled = true;
148
+ if (ctx.options.after !== undefined) body.after = ctx.options.after;
149
+ if (ctx.options.limit !== undefined) {
150
+ const limit = Number(ctx.options.limit);
151
+ if (!/^\d+$/.test(String(ctx.options.limit)) || limit < 1 || limit > 100) throw usageError('Pass --limit between 1 and 100.');
152
+ body.limit = limit;
153
+ }
154
+ const result = await httpRequest({ runtime: ctx.runtime, method: 'POST', path: '/portal_skills/inventory', body });
155
+ return ctx.output.emitSuccess({ command: 'skills list', data: result.payload,
156
+ humanSummary: skillInventoryLines(result.payload), meta: { mutating: false, request_id: result.requestId } });
157
+ },
158
+ },
159
+ {
160
+ command_path: ['skills', 'create'],
161
+ summary: 'Create a native Skill, standalone or linked to an editable Space.',
162
+ when_to_use: 'Create complete instructions or files without provider installation. Choose an optional Space destination, dry-run, and keep the exact request key on retry.',
163
+ args_schema: { arguments: [{ token: '<file>', key: 'file', description: 'JSON containing name, optional description, and skill_md or base64 files.' }],
164
+ options: [{ flags: '--request-id <key>', description: 'Stable creation intent key; reuse after an uncertain reply.' },
165
+ { flags: '--dry-run', description: 'Validate without creating an identity or uploading files.' }] },
166
+ examples: ['notis skills create ./skill.json --request-id create-1 --dry-run'],
167
+ mutates: true, idempotent: true, require_auth: true,
168
+ backend_call: { type: 'http', path: '/portal_skills/native-create' },
169
+ handler(ctx) {
170
+ const payload = parseJson(readFileSync(resolve(ctx.args.file), 'utf8'), 'Skill creation file');
171
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)
172
+ || Object.keys(payload).some(key => !['destination', 'name', 'description', 'skill_md', 'files'].includes(key))) {
173
+ throw usageError('Provide a name, complete instructions or files, and an optional current destination; never override ownership.');
174
+ }
175
+ const destination = payload.destination ?? { kind: 'standalone' };
176
+ if (!(exactKeys(destination, ['kind']) && destination.kind === 'standalone')
177
+ && !exactKeys(destination, ['space_id', 'alias', 'revision'])) throw usageError('Choose a standalone destination or exact Space, alias and revision.');
178
+ const request = ctx.options.requestId || ctx.globalOptions.idempotencyKey;
179
+ if (typeof request !== 'string' || !request || request.length > 200) throw usageError('Pass --request-id <stable-key>.');
180
+ return nativeSkillAuthoring(ctx, { ...payload, operation: 'create', destination,
181
+ request_id: request, dry_run: Boolean(ctx.options.dryRun) }, '/portal_skills/native-create');
182
+ },
183
+ },
184
+ {
185
+ command_path: ['skills', 'settings', 'read'],
186
+ summary: 'Read a native Skill personal enablement, agent targets and settings revision.',
187
+ when_to_use: 'Inspect direct-owner preferences without changing shared instructions or Space access.',
188
+ args_schema: { arguments: [{ token: '<skill-id>', key: 'skillId', description: 'Exact native Skill ID.' }], options: [] },
189
+ mutates: false, idempotent: true, require_auth: true,
190
+ backend_call: { type: 'http', path: '/portal_skills/native-settings' },
191
+ handler(ctx) { return nativeSkillAuthoring(ctx, { operation: 'read', target: { skill_id: ctx.args.skillId } }, '/portal_skills/native-settings'); },
192
+ },
193
+ {
194
+ command_path: ['skills', 'settings', 'update'],
195
+ summary: 'Update personal native Skill settings with a current revision and stable retry key.',
196
+ when_to_use: 'Enable or disable your agents without changing a shared Skill definition or another Space.',
197
+ args_schema: { arguments: [{ token: '<file>', key: 'file', description: 'JSON with target, revision and patch (enabled or agent_targets).' }],
198
+ options: [{ flags: '--request-id <key>', description: 'Stable settings intent; reuse after a lost reply.' },
199
+ { flags: '--dry-run', description: 'Validate current ownership and revision without changing settings.' }] },
200
+ mutates: true, idempotent: true, require_auth: true,
201
+ backend_call: { type: 'http', path: '/portal_skills/native-settings' },
202
+ handler(ctx) {
203
+ const payload = parseJson(readFileSync(resolve(ctx.args.file), 'utf8'), 'Skill settings file');
204
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)
205
+ || Object.keys(payload).sort().join(',') !== 'patch,revision,target'
206
+ || !payload.target || Object.keys(payload.target).sort().join(',') !== 'access_revision,skill_id') {
207
+ throw usageError('Use a current standalone target, settings revision and personal settings patch.');
208
+ }
209
+ const request = ctx.options.requestId || ctx.globalOptions.idempotencyKey;
210
+ if (typeof request !== 'string' || !request || request.length > 200) throw usageError('Pass --request-id <stable-key>.');
211
+ return nativeSkillAuthoring(ctx, { ...payload, operation: 'update', request_id: request, dry_run: Boolean(ctx.options.dryRun) }, '/portal_skills/native-settings');
212
+ },
213
+ },
214
+ {
215
+ command_path: ['skills', 'read'],
216
+ summary: 'Inspect a native Skill through direct ownership or an editable Space link.',
217
+ when_to_use: 'Read the exact target and content version before editing. A Space target uses its current Editor authority, never a personal fallback.',
218
+ args_schema: { arguments: [{ token: '[skill-id]', key: 'skillId', description: 'Exact directly owned Skill ID; omit when passing --target.' }],
219
+ options: [{ flags: '--files', description: 'Include every verified supporting file as base64.' },
220
+ { flags: '--target <json>', description: 'Exact {skill_id} or {space_id,binding_id} read target; do not combine with a Skill ID.' }] },
221
+ examples: ['notis skills read <skill-id> --files', 'notis skills read --target \'{"space_id":"<id>","binding_id":"<binding>"}\' --files'],
222
+ mutates: false, idempotent: true, require_auth: true,
223
+ backend_call: { type: 'http', path: '/portal_skills/native-authoring' },
224
+ handler(ctx) {
225
+ if (Boolean(ctx.args.skillId) === Boolean(ctx.options.target)) throw usageError('Pass either a Skill ID or --target <json>.');
226
+ const target = skillTarget(ctx.options.target ? parseJson(ctx.options.target, '--target') : { skill_id: ctx.args.skillId }, false);
227
+ return nativeSkillAuthoring(ctx, { operation: 'read', target,
228
+ include_files: Boolean(ctx.options.files) });
229
+ },
230
+ },
231
+ {
232
+ command_path: ['skills', 'update'],
233
+ summary: 'Edit a native Skill through its direct or Space target, preserving its ID and supporting files.',
234
+ when_to_use: 'Use the target/version from skills list or skills read, dry-run, then reuse the same request key and bytes to save or recover a lost reply. The same edit file works for a standalone or a Space target; an optional links key changes where the Skill is listed in the same transaction.',
235
+ args_schema: { arguments: [{ token: '<file>', key: 'file', description: 'JSON with direct or Space target, version, skill_md or base64 files, optional name/description and links {add,remove}.' }],
236
+ options: [{ flags: '--request-id <key>', description: 'Stable edit intent key; reuse after lost replies.' },
237
+ { flags: '--dry-run', description: 'Check current access, content and revisions without uploading or saving.' }] },
238
+ examples: ['notis skills update ./skill-edit.json --request-id edit-1 --dry-run'],
239
+ mutates: true, idempotent: true, require_auth: true,
240
+ backend_call: { type: 'http', path: '/portal_skills/native-authoring' },
241
+ handler(ctx) {
242
+ const payload = parseJson(readFileSync(resolve(ctx.args.file), 'utf8'), 'Skill edit file');
243
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)
244
+ || Object.keys(payload).some(key => !['target', 'version', 'skill_md', 'files', 'name', 'description', 'links'].includes(key))) {
245
+ throw usageError('Use an exact target, version, skill_md or files, and optional links; never override ownership.');
246
+ }
247
+ skillTarget(payload.target, true);
248
+ if ('links' in payload && (!payload.links || typeof payload.links !== 'object' || Array.isArray(payload.links)
249
+ || !Object.keys(payload.links).length || Object.keys(payload.links).some(key => !['add', 'remove'].includes(key)))) {
250
+ throw usageError('links must be an object with add and/or remove arrays.');
251
+ }
252
+ return nativeSkillAuthoring(ctx, { ...payload, operation: 'update', request_id: requestKey(ctx),
253
+ dry_run: Boolean(ctx.options.dryRun) });
254
+ },
255
+ },
256
+ {
257
+ command_path: ['skills', 'links'],
258
+ summary: 'Add, remove or move a Skill between Spaces; the Skill keeps its ID.',
259
+ when_to_use: 'Change where a Skill is listed without editing its content. A move is one add plus one remove in one transaction. You must be an Editor of every Space you touch and able to edit the Skill; removing its last available link keeps it as your own standalone Skill.',
260
+ args_schema: { arguments: [{ token: '<skill-id>', key: 'skillId', description: 'Exact Skill ID from skills list.' }], options: [
261
+ { flags: '--add <json>', description: 'JSON array of {space_id, space_revision, alias?} links to add; alias defaults to the Skill name.' },
262
+ { flags: '--remove <json>', description: 'JSON array of {binding_id, binding_revision} links to remove, from skills list.' },
263
+ { flags: '--request-id <key>', description: 'Stable intent key; reuse it unchanged when retrying this change.' },
264
+ ] },
265
+ examples: ['notis skills links <skill-id> --add \'[{"space_id":"<space-id>","space_revision":3}]\' --request-id link-1',
266
+ 'notis skills links <skill-id> --add \'[{"space_id":"<to-space-id>","space_revision":3}]\' --remove \'[{"binding_id":"<binding-id>","binding_revision":2}]\' --request-id move-1'],
267
+ mutates: true, idempotent: true, require_auth: true,
268
+ backend_call: { type: 'http', path: '/portal_spaces/resource-links' },
269
+ async handler(ctx) {
270
+ const links = {};
271
+ if (ctx.options.add !== undefined) links.add = linkEntries(ctx.options.add, '--add', ['space_id', 'space_revision'], ['alias']);
272
+ if (ctx.options.remove !== undefined) links.remove = linkEntries(ctx.options.remove, '--remove', ['binding_id', 'binding_revision']);
273
+ if (!Object.keys(links).length) throw usageError('Pass --add and/or --remove.');
274
+ const body = { kind: 'skill', resource_id: ctx.args.skillId, links, request_id: requestKey(ctx) };
275
+ const result = await httpRequest({ runtime: ctx.runtime, method: 'POST', path: '/portal_spaces/resource-links', body, spacesProtocol: 1 });
276
+ return ctx.output.emitSuccess({ command: 'skills links', data: result.payload,
277
+ humanSummary: linkChangeSummary(result.payload), meta: { mutating: true, request_id: result.requestId } });
278
+ },
279
+ },
51
280
  {
52
281
  command_path: ['skills', 'sync'],
53
282
  summary: 'Synchronize account skills and keep the three Notis base skills current.',