@construct-space/cli 1.7.7 → 1.8.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.
package/dist/index.js CHANGED
@@ -2876,26 +2876,92 @@ function listDesktopProfiles() {
2876
2876
  }
2877
2877
  return results;
2878
2878
  }
2879
- function credentialsPath() {
2880
- return join12(dataDir(), CREDENTIALS_FILE);
2879
+ function legacyCredentialsPath() {
2880
+ return join12(dataDir(), LEGACY_CREDENTIALS_FILE);
2881
2881
  }
2882
- function store(creds) {
2883
- const path = credentialsPath();
2882
+ function registryPath() {
2883
+ return join12(dataDir(), PROFILES_REGISTRY);
2884
+ }
2885
+ function readRegistry() {
2886
+ const path = registryPath();
2887
+ if (!existsSync10(path))
2888
+ return {};
2889
+ try {
2890
+ return JSON.parse(readFileSync7(path, "utf-8"));
2891
+ } catch {
2892
+ return {};
2893
+ }
2894
+ }
2895
+ function writeRegistry(reg) {
2896
+ const path = registryPath();
2884
2897
  mkdirSync4(dirname4(path), { recursive: true });
2885
- writeFileSync5(path, JSON.stringify(creds, null, 2) + `
2886
- `, { mode: 384 });
2898
+ writeFileSync5(path, JSON.stringify({ version: 1, ...reg }, null, 2));
2887
2899
  }
2888
- function load2() {
2889
- const path = credentialsPath();
2890
- if (existsSync10(path)) {
2891
- const data = JSON.parse(readFileSync7(path, "utf-8"));
2892
- if (data.token) {
2893
- if (!data.publisherKey && data.token.startsWith("csk_live_")) {
2894
- data.publisherKey = data.token;
2895
- }
2896
- return data;
2897
- }
2900
+ function store(creds) {
2901
+ const profileId = creds.profileId || creds.user?.id;
2902
+ if (!profileId) {
2903
+ throw new Error("cannot store credentials without a profile id (user.id or profileId)");
2904
+ }
2905
+ const profilePath = join12(profilesDir(), profileId, "auth.json");
2906
+ mkdirSync4(dirname4(profilePath), { recursive: true });
2907
+ let existing = {};
2908
+ if (existsSync10(profilePath)) {
2909
+ try {
2910
+ existing = JSON.parse(readFileSync7(profilePath, "utf-8"));
2911
+ } catch {}
2912
+ }
2913
+ existing.token = creds.token;
2914
+ existing.authenticated = true;
2915
+ existing.updated_at = new Date().toISOString();
2916
+ if (creds.user) {
2917
+ existing.user = { ...existing.user, ...creds.user };
2898
2918
  }
2919
+ if (creds.publisherKey) {
2920
+ existing.publisher = {
2921
+ ...existing.publisher,
2922
+ name: creds.publisherName || existing.publisher?.name || "",
2923
+ kind: creds.publisherKind || existing.publisher?.kind || "user",
2924
+ api_key: creds.publisherKey
2925
+ };
2926
+ }
2927
+ writeFileSync5(profilePath, JSON.stringify(existing, null, 2), { mode: 384 });
2928
+ const reg = readRegistry();
2929
+ reg.active_profile = profileId;
2930
+ reg.profiles = reg.profiles || [];
2931
+ const existingEntry = reg.profiles.find((p) => p.id === profileId);
2932
+ if (existingEntry) {
2933
+ if (creds.user?.name)
2934
+ existingEntry.name = creds.user.name;
2935
+ if (creds.user?.email)
2936
+ existingEntry.email = creds.user.email;
2937
+ } else if (creds.user) {
2938
+ reg.profiles.push({
2939
+ id: profileId,
2940
+ name: creds.user.name,
2941
+ email: creds.user.email
2942
+ });
2943
+ }
2944
+ writeRegistry(reg);
2945
+ }
2946
+ function migrateLegacyCredentials() {
2947
+ const legacy = legacyCredentialsPath();
2948
+ if (!existsSync10(legacy))
2949
+ return;
2950
+ try {
2951
+ const data = JSON.parse(readFileSync7(legacy, "utf-8"));
2952
+ if (!data.token)
2953
+ return;
2954
+ const pid = data.profileId || data.user?.id;
2955
+ if (!pid)
2956
+ return;
2957
+ const profilePath = join12(profilesDir(), pid, "auth.json");
2958
+ if (existsSync10(profilePath))
2959
+ return;
2960
+ store({ ...data, profileId: pid });
2961
+ } catch {}
2962
+ }
2963
+ function load2() {
2964
+ migrateLegacyCredentials();
2899
2965
  const fromProfile = loadFromActiveProfile();
2900
2966
  if (fromProfile)
2901
2967
  return fromProfile;
@@ -2945,11 +3011,24 @@ function isAuthenticated() {
2945
3011
  }
2946
3012
  }
2947
3013
  function clear() {
2948
- const path = credentialsPath();
2949
- if (existsSync10(path))
2950
- unlinkSync(path);
3014
+ const reg = readRegistry();
3015
+ const activeId = reg.active_profile;
3016
+ if (activeId) {
3017
+ const profilePath = join12(profilesDir(), activeId, "auth.json");
3018
+ if (existsSync10(profilePath)) {
3019
+ try {
3020
+ const data = JSON.parse(readFileSync7(profilePath, "utf-8"));
3021
+ data.authenticated = false;
3022
+ data.updated_at = new Date().toISOString();
3023
+ writeFileSync5(profilePath, JSON.stringify(data, null, 2), { mode: 384 });
3024
+ } catch {}
3025
+ }
3026
+ }
3027
+ const legacy = legacyCredentialsPath();
3028
+ if (existsSync10(legacy))
3029
+ unlinkSync(legacy);
2951
3030
  }
2952
- var CREDENTIALS_FILE = "credentials.json", DEFAULT_PORTAL = "https://my.construct.space/api/developer";
3031
+ var LEGACY_CREDENTIALS_FILE = "credentials.json", PROFILES_REGISTRY = "profiles.json", DEFAULT_PORTAL = "https://my.construct.space/api/developer";
2953
3032
  var init_auth = __esm(() => {
2954
3033
  init_appdir();
2955
3034
  });
@@ -8527,16 +8606,16 @@ import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
8527
8606
  import { join as join9 } from "path";
8528
8607
  import { createHash as createHash2 } from "crypto";
8529
8608
 
8530
- // node_modules/chokidar/index.js
8609
+ // node_modules/chokidar/esm/index.js
8610
+ import { stat as statcb } from "fs";
8611
+ import { stat as stat3, readdir as readdir2 } from "fs/promises";
8531
8612
  import { EventEmitter } from "events";
8532
- import { stat as statcb, Stats } from "fs";
8533
- import { readdir as readdir2, stat as stat3 } from "fs/promises";
8534
- import * as sp2 from "path";
8613
+ import * as sysPath2 from "path";
8535
8614
 
8536
- // node_modules/readdirp/index.js
8537
- import { lstat, readdir, realpath, stat } from "fs/promises";
8538
- import { join as pjoin, relative as prelative, resolve as presolve, sep as psep } from "path";
8615
+ // node_modules/readdirp/esm/index.js
8616
+ import { stat, lstat, readdir, realpath } from "fs/promises";
8539
8617
  import { Readable } from "stream";
8618
+ import { resolve as presolve, relative as prelative, join as pjoin, sep as psep } from "path";
8540
8619
  var EntryTypes = {
8541
8620
  FILE_TYPE: "files",
8542
8621
  DIR_TYPE: "directories",
@@ -8592,20 +8671,6 @@ var normalizeFilter = (filter) => {
8592
8671
  };
8593
8672
 
8594
8673
  class ReaddirpStream extends Readable {
8595
- parents;
8596
- reading;
8597
- parent;
8598
- _stat;
8599
- _maxDepth;
8600
- _wantsDir;
8601
- _wantsFile;
8602
- _wantsEverything;
8603
- _root;
8604
- _isDirent;
8605
- _statsProp;
8606
- _rdOptions;
8607
- _fileFilter;
8608
- _directoryFilter;
8609
8674
  constructor(options = {}) {
8610
8675
  super({
8611
8676
  objectMode: true,
@@ -8622,7 +8687,7 @@ class ReaddirpStream extends Readable {
8622
8687
  } else {
8623
8688
  this._stat = statMethod;
8624
8689
  }
8625
- this._maxDepth = opts.depth != null && Number.isSafeInteger(opts.depth) ? opts.depth : defaultOptions.depth;
8690
+ this._maxDepth = opts.depth ?? defaultOptions.depth;
8626
8691
  this._wantsDir = type ? DIR_TYPES.has(type) : false;
8627
8692
  this._wantsFile = type ? FILE_TYPES.has(type) : false;
8628
8693
  this._wantsEverything = type === EntryTypes.EVERYTHING_TYPE;
@@ -8767,11 +8832,11 @@ function readdirp(root, options = {}) {
8767
8832
  return new ReaddirpStream(options);
8768
8833
  }
8769
8834
 
8770
- // node_modules/chokidar/handler.js
8771
- import { watch as fs_watch, unwatchFile, watchFile } from "fs";
8772
- import { realpath as fsrealpath, lstat as lstat2, open, stat as stat2 } from "fs/promises";
8835
+ // node_modules/chokidar/esm/handler.js
8836
+ import { watchFile, unwatchFile, watch as fs_watch } from "fs";
8837
+ import { open, stat as stat2, lstat as lstat2, realpath as fsrealpath } from "fs/promises";
8838
+ import * as sysPath from "path";
8773
8839
  import { type as osType } from "os";
8774
- import * as sp from "path";
8775
8840
  var STR_DATA = "data";
8776
8841
  var STR_END = "end";
8777
8842
  var STR_CLOSE = "close";
@@ -9063,7 +9128,7 @@ var binaryExtensions = new Set([
9063
9128
  "zip",
9064
9129
  "zipx"
9065
9130
  ]);
9066
- var isBinaryPath = (filePath) => binaryExtensions.has(sp.extname(filePath).slice(1).toLowerCase());
9131
+ var isBinaryPath = (filePath) => binaryExtensions.has(sysPath.extname(filePath).slice(1).toLowerCase());
9067
9132
  var foreach = (val, fn) => {
9068
9133
  if (val instanceof Set) {
9069
9134
  val.forEach(fn);
@@ -9101,7 +9166,7 @@ function createFsWatchInstance(path, options, listener, errHandler, emitRaw) {
9101
9166
  listener(path);
9102
9167
  emitRaw(rawEvent, evPath, { watchedPath: path });
9103
9168
  if (evPath && path !== evPath) {
9104
- fsWatchBroadcast(sp.resolve(path, evPath), KEY_LISTENERS, sp.join(path, evPath));
9169
+ fsWatchBroadcast(sysPath.resolve(path, evPath), KEY_LISTENERS, sysPath.join(path, evPath));
9105
9170
  }
9106
9171
  };
9107
9172
  try {
@@ -9216,19 +9281,17 @@ var setFsWatchFileListener = (path, fullPath, options, handlers) => {
9216
9281
  };
9217
9282
 
9218
9283
  class NodeFsHandler {
9219
- fsw;
9220
- _boundHandleError;
9221
9284
  constructor(fsW) {
9222
9285
  this.fsw = fsW;
9223
9286
  this._boundHandleError = (error2) => fsW._handleError(error2);
9224
9287
  }
9225
9288
  _watchWithNodeFs(path, listener) {
9226
9289
  const opts = this.fsw.options;
9227
- const directory = sp.dirname(path);
9228
- const basename4 = sp.basename(path);
9290
+ const directory = sysPath.dirname(path);
9291
+ const basename4 = sysPath.basename(path);
9229
9292
  const parent = this.fsw._getWatchedDir(directory);
9230
9293
  parent.add(basename4);
9231
- const absolutePath = sp.resolve(path);
9294
+ const absolutePath = sysPath.resolve(path);
9232
9295
  const options = {
9233
9296
  persistent: opts.persistent
9234
9297
  };
@@ -9255,8 +9318,8 @@ class NodeFsHandler {
9255
9318
  if (this.fsw.closed) {
9256
9319
  return;
9257
9320
  }
9258
- const dirname3 = sp.dirname(file);
9259
- const basename4 = sp.basename(file);
9321
+ const dirname3 = sysPath.dirname(file);
9322
+ const basename4 = sysPath.basename(file);
9260
9323
  const parent = this.fsw._getWatchedDir(dirname3);
9261
9324
  let prevStats = stats;
9262
9325
  if (parent.has(basename4))
@@ -9339,9 +9402,8 @@ class NodeFsHandler {
9339
9402
  this.fsw._symlinkPaths.set(full, true);
9340
9403
  }
9341
9404
  _handleRead(directory, initialAdd, wh, target, dir, depth, throttler) {
9342
- directory = sp.join(directory, "");
9343
- const throttleKey = target ? `${directory}:${target}` : directory;
9344
- throttler = this.fsw._throttle("readdir", throttleKey, 1000);
9405
+ directory = sysPath.join(directory, "");
9406
+ throttler = this.fsw._throttle("readdir", directory, 1000);
9345
9407
  if (!throttler)
9346
9408
  return;
9347
9409
  const previous = this.fsw._getWatchedDir(wh.path);
@@ -9358,7 +9420,7 @@ class NodeFsHandler {
9358
9420
  return;
9359
9421
  }
9360
9422
  const item = entry.path;
9361
- let path = sp.join(directory, item);
9423
+ let path = sysPath.join(directory, item);
9362
9424
  current.add(item);
9363
9425
  if (entry.stats.isSymbolicLink() && await this._handleSymlink(entry, directory, path, item)) {
9364
9426
  return;
@@ -9369,7 +9431,7 @@ class NodeFsHandler {
9369
9431
  }
9370
9432
  if (item === target || !target && !previous.has(item)) {
9371
9433
  this.fsw._incrReadyCount();
9372
- path = sp.join(dir, sp.relative(dir, path));
9434
+ path = sysPath.join(dir, sysPath.relative(dir, path));
9373
9435
  this._addToNodeFs(path, initialAdd, wh, depth + 1);
9374
9436
  }
9375
9437
  }).on(EV.ERROR, this._boundHandleError);
@@ -9395,12 +9457,12 @@ class NodeFsHandler {
9395
9457
  });
9396
9458
  }
9397
9459
  async _handleDir(dir, stats, initialAdd, depth, target, wh, realpath2) {
9398
- const parentDir = this.fsw._getWatchedDir(sp.dirname(dir));
9399
- const tracked = parentDir.has(sp.basename(dir));
9460
+ const parentDir = this.fsw._getWatchedDir(sysPath.dirname(dir));
9461
+ const tracked = parentDir.has(sysPath.basename(dir));
9400
9462
  if (!(initialAdd && this.fsw.options.ignoreInitial) && !target && !tracked) {
9401
9463
  this.fsw._emit(EV.ADD_DIR, dir, stats);
9402
9464
  }
9403
- parentDir.add(sp.basename(dir));
9465
+ parentDir.add(sysPath.basename(dir));
9404
9466
  this.fsw._getWatchedDir(dir);
9405
9467
  let throttler;
9406
9468
  let closer;
@@ -9441,7 +9503,7 @@ class NodeFsHandler {
9441
9503
  const follow = this.fsw.options.followSymlinks;
9442
9504
  let closer;
9443
9505
  if (stats.isDirectory()) {
9444
- const absPath = sp.resolve(path);
9506
+ const absPath = sysPath.resolve(path);
9445
9507
  const targetPath = follow ? await fsrealpath(path) : path;
9446
9508
  if (this.fsw.closed)
9447
9509
  return;
@@ -9455,14 +9517,14 @@ class NodeFsHandler {
9455
9517
  const targetPath = follow ? await fsrealpath(path) : path;
9456
9518
  if (this.fsw.closed)
9457
9519
  return;
9458
- const parent = sp.dirname(wh.watchPath);
9520
+ const parent = sysPath.dirname(wh.watchPath);
9459
9521
  this.fsw._getWatchedDir(parent).add(wh.watchPath);
9460
9522
  this.fsw._emit(EV.ADD, wh.watchPath, stats);
9461
9523
  closer = await this._handleDir(parent, stats, initialAdd, depth, path, wh, targetPath);
9462
9524
  if (this.fsw.closed)
9463
9525
  return;
9464
9526
  if (targetPath !== undefined) {
9465
- this.fsw._symlinkPaths.set(sp.resolve(path), targetPath);
9527
+ this.fsw._symlinkPaths.set(sysPath.resolve(path), targetPath);
9466
9528
  }
9467
9529
  } else {
9468
9530
  closer = this._handleFile(wh.watchPath, stats, initialAdd);
@@ -9480,7 +9542,7 @@ class NodeFsHandler {
9480
9542
  }
9481
9543
  }
9482
9544
 
9483
- // node_modules/chokidar/index.js
9545
+ // node_modules/chokidar/esm/index.js
9484
9546
  /*! chokidar - MIT License (c) 2012 Paul Miller (paulmillr.com) */
9485
9547
  var SLASH = "/";
9486
9548
  var SLASH_SLASH = "//";
@@ -9488,7 +9550,7 @@ var ONE_DOT = ".";
9488
9550
  var TWO_DOTS = "..";
9489
9551
  var STRING_TYPE = "string";
9490
9552
  var BACK_SLASH_RE = /\\/g;
9491
- var DOUBLE_SLASH_RE = /\/\//g;
9553
+ var DOUBLE_SLASH_RE = /\/\//;
9492
9554
  var DOT_RE = /\..*\.(sw[px])$|~$|\.subl.*\.tmp/;
9493
9555
  var REPLACER_RE = /^\.[/\\]/;
9494
9556
  function arrify(item) {
@@ -9507,11 +9569,11 @@ function createPattern(matcher) {
9507
9569
  if (matcher.path === string)
9508
9570
  return true;
9509
9571
  if (matcher.recursive) {
9510
- const relative4 = sp2.relative(matcher.path, string);
9572
+ const relative4 = sysPath2.relative(matcher.path, string);
9511
9573
  if (!relative4) {
9512
9574
  return false;
9513
9575
  }
9514
- return !relative4.startsWith("..") && !sp2.isAbsolute(relative4);
9576
+ return !relative4.startsWith("..") && !sysPath2.isAbsolute(relative4);
9515
9577
  }
9516
9578
  return false;
9517
9579
  };
@@ -9521,12 +9583,14 @@ function createPattern(matcher) {
9521
9583
  function normalizePath(path) {
9522
9584
  if (typeof path !== "string")
9523
9585
  throw new Error("string expected");
9524
- path = sp2.normalize(path);
9586
+ path = sysPath2.normalize(path);
9525
9587
  path = path.replace(/\\/g, "/");
9526
9588
  let prepend = false;
9527
9589
  if (path.startsWith("//"))
9528
9590
  prepend = true;
9529
- path = path.replace(DOUBLE_SLASH_RE, "/");
9591
+ const DOUBLE_SLASH_RE2 = /\/\//;
9592
+ while (path.match(DOUBLE_SLASH_RE2))
9593
+ path = path.replace(DOUBLE_SLASH_RE2, "/");
9530
9594
  if (prepend)
9531
9595
  path = "/" + path;
9532
9596
  return path;
@@ -9567,32 +9631,31 @@ var toUnix = (string) => {
9567
9631
  if (str.startsWith(SLASH_SLASH)) {
9568
9632
  prepend = true;
9569
9633
  }
9570
- str = str.replace(DOUBLE_SLASH_RE, SLASH);
9634
+ while (str.match(DOUBLE_SLASH_RE)) {
9635
+ str = str.replace(DOUBLE_SLASH_RE, SLASH);
9636
+ }
9571
9637
  if (prepend) {
9572
9638
  str = SLASH + str;
9573
9639
  }
9574
9640
  return str;
9575
9641
  };
9576
- var normalizePathToUnix = (path) => toUnix(sp2.normalize(toUnix(path)));
9642
+ var normalizePathToUnix = (path) => toUnix(sysPath2.normalize(toUnix(path)));
9577
9643
  var normalizeIgnored = (cwd = "") => (path) => {
9578
9644
  if (typeof path === "string") {
9579
- return normalizePathToUnix(sp2.isAbsolute(path) ? path : sp2.join(cwd, path));
9645
+ return normalizePathToUnix(sysPath2.isAbsolute(path) ? path : sysPath2.join(cwd, path));
9580
9646
  } else {
9581
9647
  return path;
9582
9648
  }
9583
9649
  };
9584
9650
  var getAbsolutePath = (path, cwd) => {
9585
- if (sp2.isAbsolute(path)) {
9651
+ if (sysPath2.isAbsolute(path)) {
9586
9652
  return path;
9587
9653
  }
9588
- return sp2.join(cwd, path);
9654
+ return sysPath2.join(cwd, path);
9589
9655
  };
9590
9656
  var EMPTY_SET = Object.freeze(new Set);
9591
9657
 
9592
9658
  class DirEntry {
9593
- path;
9594
- _removeWatcher;
9595
- items;
9596
9659
  constructor(dir, removeWatcher) {
9597
9660
  this.path = dir;
9598
9661
  this._removeWatcher = removeWatcher;
@@ -9617,7 +9680,7 @@ class DirEntry {
9617
9680
  await readdir2(dir);
9618
9681
  } catch (err) {
9619
9682
  if (this._removeWatcher) {
9620
- this._removeWatcher(sp2.dirname(dir), sp2.basename(dir));
9683
+ this._removeWatcher(sysPath2.dirname(dir), sysPath2.basename(dir));
9621
9684
  }
9622
9685
  }
9623
9686
  }
@@ -9645,19 +9708,12 @@ var STAT_METHOD_F = "stat";
9645
9708
  var STAT_METHOD_L = "lstat";
9646
9709
 
9647
9710
  class WatchHelper {
9648
- fsw;
9649
- path;
9650
- watchPath;
9651
- fullWatchPath;
9652
- dirParts;
9653
- followSymlinks;
9654
- statMethod;
9655
9711
  constructor(path, follow, fsw) {
9656
9712
  this.fsw = fsw;
9657
9713
  const watchPath = path;
9658
9714
  this.path = path = path.replace(REPLACER_RE, "");
9659
9715
  this.watchPath = watchPath;
9660
- this.fullWatchPath = sp2.resolve(watchPath);
9716
+ this.fullWatchPath = sysPath2.resolve(watchPath);
9661
9717
  this.dirParts = [];
9662
9718
  this.dirParts.forEach((parts) => {
9663
9719
  if (parts.length > 1)
@@ -9667,7 +9723,7 @@ class WatchHelper {
9667
9723
  this.statMethod = follow ? STAT_METHOD_F : STAT_METHOD_L;
9668
9724
  }
9669
9725
  entryPath(entry) {
9670
- return sp2.join(this.watchPath, sp2.relative(this.watchPath, entry.fullPath));
9726
+ return sysPath2.join(this.watchPath, sysPath2.relative(this.watchPath, entry.fullPath));
9671
9727
  }
9672
9728
  filterPath(entry) {
9673
9729
  const { stats } = entry;
@@ -9682,24 +9738,6 @@ class WatchHelper {
9682
9738
  }
9683
9739
 
9684
9740
  class FSWatcher extends EventEmitter {
9685
- closed;
9686
- options;
9687
- _closers;
9688
- _ignoredPaths;
9689
- _throttled;
9690
- _streams;
9691
- _symlinkPaths;
9692
- _watched;
9693
- _pendingWrites;
9694
- _pendingUnlinks;
9695
- _readyCount;
9696
- _emitReady;
9697
- _closePromise;
9698
- _userIgnored;
9699
- _readyEmitted;
9700
- _emitRaw;
9701
- _boundRemove;
9702
- _nodeFsHandler;
9703
9741
  constructor(_opts = {}) {
9704
9742
  super();
9705
9743
  this.closed = false;
@@ -9808,7 +9846,7 @@ class FSWatcher extends EventEmitter {
9808
9846
  return;
9809
9847
  results.forEach((item) => {
9810
9848
  if (item)
9811
- this.add(sp2.dirname(item), sp2.basename(_origAdd || item));
9849
+ this.add(sysPath2.dirname(item), sysPath2.basename(_origAdd || item));
9812
9850
  });
9813
9851
  });
9814
9852
  return this;
@@ -9819,10 +9857,10 @@ class FSWatcher extends EventEmitter {
9819
9857
  const paths = unifyPaths(paths_);
9820
9858
  const { cwd } = this.options;
9821
9859
  paths.forEach((path) => {
9822
- if (!sp2.isAbsolute(path) && !this._closers.has(path)) {
9860
+ if (!sysPath2.isAbsolute(path) && !this._closers.has(path)) {
9823
9861
  if (cwd)
9824
- path = sp2.join(cwd, path);
9825
- path = sp2.resolve(path);
9862
+ path = sysPath2.join(cwd, path);
9863
+ path = sysPath2.resolve(path);
9826
9864
  }
9827
9865
  this._closePath(path);
9828
9866
  this._addIgnoredPath(path);
@@ -9866,7 +9904,7 @@ class FSWatcher extends EventEmitter {
9866
9904
  getWatched() {
9867
9905
  const watchList = {};
9868
9906
  this._watched.forEach((entry, dir) => {
9869
- const key = this.options.cwd ? sp2.relative(this.options.cwd, dir) : dir;
9907
+ const key = this.options.cwd ? sysPath2.relative(this.options.cwd, dir) : dir;
9870
9908
  const index = key || ONE_DOT;
9871
9909
  watchList[index] = entry.getChildren().sort();
9872
9910
  });
@@ -9882,9 +9920,9 @@ class FSWatcher extends EventEmitter {
9882
9920
  return;
9883
9921
  const opts = this.options;
9884
9922
  if (isWindows)
9885
- path = sp2.normalize(path);
9923
+ path = sysPath2.normalize(path);
9886
9924
  if (opts.cwd)
9887
- path = sp2.relative(opts.cwd, path);
9925
+ path = sysPath2.relative(opts.cwd, path);
9888
9926
  const args = [path];
9889
9927
  if (stats != null)
9890
9928
  args.push(stats);
@@ -9935,7 +9973,7 @@ class FSWatcher extends EventEmitter {
9935
9973
  return this;
9936
9974
  }
9937
9975
  if (opts.alwaysStat && stats === undefined && (event === EVENTS.ADD || event === EVENTS.ADD_DIR || event === EVENTS.CHANGE)) {
9938
- const fullPath = opts.cwd ? sp2.join(opts.cwd, path) : path;
9976
+ const fullPath = opts.cwd ? sysPath2.join(opts.cwd, path) : path;
9939
9977
  let stats2;
9940
9978
  try {
9941
9979
  stats2 = await stat3(fullPath);
@@ -9991,8 +10029,8 @@ class FSWatcher extends EventEmitter {
9991
10029
  const pollInterval = awf.pollInterval;
9992
10030
  let timeoutHandler;
9993
10031
  let fullPath = path;
9994
- if (this.options.cwd && !sp2.isAbsolute(path)) {
9995
- fullPath = sp2.join(this.options.cwd, path);
10032
+ if (this.options.cwd && !sysPath2.isAbsolute(path)) {
10033
+ fullPath = sysPath2.join(this.options.cwd, path);
9996
10034
  }
9997
10035
  const now = new Date;
9998
10036
  const writes = this._pendingWrites;
@@ -10049,7 +10087,7 @@ class FSWatcher extends EventEmitter {
10049
10087
  return new WatchHelper(path, this.options.followSymlinks, this);
10050
10088
  }
10051
10089
  _getWatchedDir(directory) {
10052
- const dir = sp2.resolve(directory);
10090
+ const dir = sysPath2.resolve(directory);
10053
10091
  if (!this._watched.has(dir))
10054
10092
  this._watched.set(dir, new DirEntry(dir, this._boundRemove));
10055
10093
  return this._watched.get(dir);
@@ -10060,8 +10098,8 @@ class FSWatcher extends EventEmitter {
10060
10098
  return Boolean(Number(stats.mode) & 256);
10061
10099
  }
10062
10100
  _remove(directory, item, isDirectory) {
10063
- const path = sp2.join(directory, item);
10064
- const fullPath = sp2.resolve(path);
10101
+ const path = sysPath2.join(directory, item);
10102
+ const fullPath = sysPath2.resolve(path);
10065
10103
  isDirectory = isDirectory != null ? isDirectory : this._watched.has(path) || this._watched.has(fullPath);
10066
10104
  if (!this._throttle("remove", path, 100))
10067
10105
  return;
@@ -10079,7 +10117,7 @@ class FSWatcher extends EventEmitter {
10079
10117
  }
10080
10118
  let relPath = path;
10081
10119
  if (this.options.cwd)
10082
- relPath = sp2.relative(this.options.cwd, path);
10120
+ relPath = sysPath2.relative(this.options.cwd, path);
10083
10121
  if (this.options.awaitWriteFinish && this._pendingWrites.has(relPath)) {
10084
10122
  const event = this._pendingWrites.get(relPath).cancelWait();
10085
10123
  if (event === EVENTS.ADD)
@@ -10094,8 +10132,8 @@ class FSWatcher extends EventEmitter {
10094
10132
  }
10095
10133
  _closePath(path) {
10096
10134
  this._closeFile(path);
10097
- const dir = sp2.dirname(path);
10098
- this._getWatchedDir(dir).remove(sp2.basename(path));
10135
+ const dir = sysPath2.dirname(path);
10136
+ this._getWatchedDir(dir).remove(sysPath2.basename(path));
10099
10137
  }
10100
10138
  _closeFile(path) {
10101
10139
  const closers = this._closers.get(path);
@@ -10389,7 +10427,10 @@ async function uploadSource(portalURL, identityToken, publisherKey, tarballPath,
10389
10427
  }
10390
10428
  msg += `
10391
10429
  Fork to a new space_id to publish your own version.`;
10392
- throw new Error(msg);
10430
+ const err = new Error(msg);
10431
+ err.ownerKind = result.owner_kind;
10432
+ err.ownerOrgId = result.owner_org_id;
10433
+ throw err;
10393
10434
  }
10394
10435
  if (resp.status >= 400) {
10395
10436
  const msg = result.error || result.errors?.join("; ") || `server returned ${resp.status}`;
@@ -10397,6 +10438,20 @@ async function uploadSource(portalURL, identityToken, publisherKey, tarballPath,
10397
10438
  }
10398
10439
  return result;
10399
10440
  }
10441
+ async function fetchOrgPublisherKey(portalURL, identityToken) {
10442
+ const root = portalURL.replace(/\/api\/developer\/?$/, "").replace(/\/+$/, "");
10443
+ try {
10444
+ const resp = await fetch(`${root}/api/publisher/org`, {
10445
+ headers: { Authorization: `Bearer ${identityToken}` }
10446
+ });
10447
+ if (!resp.ok)
10448
+ return null;
10449
+ const body = await resp.json();
10450
+ return body.publisher?.api_key || null;
10451
+ } catch {
10452
+ return null;
10453
+ }
10454
+ }
10400
10455
  function setVersionInFiles(root, oldVer, newVer) {
10401
10456
  const oldStr = `"version": "${oldVer}"`;
10402
10457
  const newStr = `"version": "${newVer}"`;
@@ -10553,7 +10608,23 @@ async function publish(options) {
10553
10608
  }
10554
10609
  const uploadSpinner = ora("Uploading & building...").start();
10555
10610
  try {
10556
- const result = await uploadSource(creds.portal, creds.token, creds.publisherKey, tarballPath, m, { private: wantPrivate, public: wantPublic });
10611
+ const explicitKey = options?.apiKey || process.env.CONSTRUCT_PUBLISHER_KEY;
10612
+ const initialKey = explicitKey || creds.publisherKey;
10613
+ let result;
10614
+ try {
10615
+ result = await uploadSource(creds.portal, creds.token, initialKey, tarballPath, m, { private: wantPrivate, public: wantPublic });
10616
+ } catch (e) {
10617
+ if (e?.ownerKind === "org" && !creds.publisherKey) {
10618
+ uploadSpinner.text = "Fetching org publisher key\u2026";
10619
+ const orgKey = await fetchOrgPublisherKey(creds.portal, creds.token);
10620
+ if (!orgKey)
10621
+ throw e;
10622
+ uploadSpinner.text = "Uploading & building (as org)\u2026";
10623
+ result = await uploadSource(creds.portal, creds.token, orgKey, tarballPath, m, { private: wantPrivate, public: wantPublic });
10624
+ } else {
10625
+ throw e;
10626
+ }
10627
+ }
10557
10628
  unlinkSync2(tarballPath);
10558
10629
  gitSafe(root, "tag", `v${m.version}`);
10559
10630
  gitSafe(root, "push", "origin", `v${m.version}`);
@@ -10794,6 +10865,19 @@ async function loginFromProfile(profiles) {
10794
10865
  console.log(source_default.dim(` profile: ${picked.id}`));
10795
10866
  }
10796
10867
  async function loginWithToken(token) {
10868
+ if (token.startsWith("cst_live_")) {
10869
+ console.error(source_default.red("cst_live_* tokens are being retired."));
10870
+ console.error(source_default.dim(" Sign in via the Construct desktop app, or get a new"));
10871
+ console.error(source_default.dim(` identity token at ${source_default.cyan("https://my.construct.space/settings/tokens")}.`));
10872
+ process.exit(1);
10873
+ }
10874
+ if (token.startsWith("csk_live_")) {
10875
+ console.error(source_default.red("That's a publisher key, not an identity token."));
10876
+ console.error(source_default.dim(" Publisher keys (csk_live_*) authorize publish-as-publisher"));
10877
+ console.error(source_default.dim(" on top of identity. Pass an identity token (cat_*) here;"));
10878
+ console.error(source_default.dim(" the CLI loads your publisher key automatically once logged in."));
10879
+ process.exit(1);
10880
+ }
10797
10881
  let resp;
10798
10882
  try {
10799
10883
  resp = await fetch(ACCOUNTS_SCOPE_URL, {
@@ -11509,7 +11593,7 @@ function graphFork(newSpaceID) {
11509
11593
  // package.json
11510
11594
  var package_default = {
11511
11595
  name: "@construct-space/cli",
11512
- version: "1.7.7",
11596
+ version: "1.8.1",
11513
11597
  description: "Construct CLI \u2014 scaffold, build, develop, and publish spaces",
11514
11598
  type: "module",
11515
11599
  bin: {
@@ -11561,7 +11645,7 @@ program2.command("scaffold [name]").alias("new").alias("create").description("Cr
11561
11645
  program2.command("build").description("Build the space (generate entry + run Vite)").option("--entry-only", "Only generate src/entry.ts").action(async (opts) => build(opts));
11562
11646
  program2.command("dev").description("Start dev mode with file watching and live reload").action(async () => dev());
11563
11647
  program2.command("install").alias("run").description("Install built space to Construct spaces directory").action(() => install());
11564
- program2.command("publish").description("Publish a space to the Construct registry").option("-y, --yes", "Skip all confirmation prompts").option("--bump <type>", "Auto-bump version (patch, minor, major)").option("--private", "Publish as org-private (catalog-listed only inside the owning org). Requires an org publisher key.").option("--public", "Flip a previously-private space back to the public catalog on this publish. Without --private or --public, visibility is preserved on update (and defaults to public on first publish).").action(async (opts) => publish(opts));
11648
+ program2.command("publish").description("Publish a space to the Construct registry").option("-y, --yes", "Skip all confirmation prompts").option("--bump <type>", "Auto-bump version (patch, minor, major)").option("--private", "Publish as org-private (catalog-listed only inside the owning org). Requires an org publisher key.").option("--public", "Flip a previously-private space back to the public catalog on this publish. Without --private or --public, visibility is preserved on update (and defaults to public on first publish).").option("--api-key <key>", "Publisher API key (csk_live_\u2026). Overrides any key stored in the active profile. Also reads CONSTRUCT_PUBLISHER_KEY.").action(async (opts) => publish(opts));
11565
11649
  program2.command("validate").description("Validate space.manifest.json").action(() => validate3());
11566
11650
  program2.command("check").description("Run type-check (vue-tsc) and linter (eslint)").action(() => check());
11567
11651
  program2.command("clean").description("Remove build artifacts").option("--all", "Also remove node_modules and lockfiles").action((opts) => clean(opts));
@@ -11592,7 +11676,7 @@ space.command("scaffold [name]").alias("new").alias("create").option("--with-tes
11592
11676
  space.command("build").option("--entry-only").action(async (opts) => build(opts));
11593
11677
  space.command("dev").action(async () => dev());
11594
11678
  space.command("install").alias("run").action(() => install());
11595
- space.command("publish").option("-y, --yes").option("--bump <type>").option("--private").option("--public").action(async (opts) => publish(opts));
11679
+ space.command("publish").option("-y, --yes").option("--bump <type>").option("--private").option("--public").option("--api-key <key>").action(async (opts) => publish(opts));
11596
11680
  space.command("validate").action(() => validate3());
11597
11681
  space.command("check").action(() => check());
11598
11682
  space.command("clean").option("--all").action((opts) => clean(opts));
@@ -1,26 +1,58 @@
1
1
  /**
2
2
  * Space Actions — exposed to the AI agent via space_run_action.
3
3
  *
4
- * Each action: { description, params, run }.
5
- * - `params` describe agent-callable inputs (type, description, required).
6
- * - `run` receives the payload and returns any JSON-serialisable value.
4
+ * Each action: { description, params, run, tier? }.
5
+ * - `params` JSON-schema-ish input shape; each: { type, description?, required? }.
6
+ * - `run` receives the validated payload, returns any JSON-serialisable value.
7
+ * - `tier` (optional) default model bucket for useBrain() calls inside `run`:
8
+ * 'small' fast/cheap — summarisation, classification, short Q&A
9
+ * 'medium' balanced — general help, edits, structured output
10
+ * 'large' reasoning — long-form writing, deep code, planning
11
+ * The host resolves tier → provider+model via the user's tier config
12
+ * (Settings → LLM Providers). Per-call `brain.complete({ tier })`
13
+ * wins over the action default.
14
+ *
15
+ * Permission for useBrain():
16
+ * - Declare `{{.ID}}:brain` in `permissions.catalog` (space.manifest.json).
17
+ * - Add the action to `permissions.actions` mapping to that id, e.g.
18
+ * "permissions": {
19
+ * "actions": { "summarize": "{{.ID}}:brain" },
20
+ * "catalog": [{ "id": "{{.ID}}:brain", "label": "Use AI summaries" }]
21
+ * }
7
22
  *
8
23
  * Pair with @construct-space/graph for typed multi-tenant data:
9
24
  * import { useGraph } from '@construct-space/graph'
10
25
  * import { Item } from './models'
11
26
  * const items = useGraph(Item)
12
- *
13
- * listItems: {
14
- * description: 'List items',
15
- * params: { limit: { type: 'number', required: false } },
16
- * run: async (p: any) => ({ items: await items.find({ limit: p.limit ?? 50 }) }),
17
- * }
18
27
  */
19
28
 
20
- export const actions = {
29
+ import { useBrain } from '@construct-space/sdk'
30
+ import type { SpaceActions } from '@construct-space/sdk'
31
+
32
+ export const actions: SpaceActions = {
21
33
  ping: {
22
34
  description: 'Health check — returns pong with the space id.',
23
35
  params: {},
24
36
  run: () => ({ pong: true, space: '{{.ID}}' }),
25
37
  },
38
+
39
+ // Example: a tier-routed action. Delete or adapt for your space.
40
+ // Requires '{{.ID}}:brain' in permissions.catalog and a row in
41
+ // permissions.actions mapping 'summarize' to that id.
42
+ //
43
+ // summarize: {
44
+ // description: 'Summarise the given text in 1-2 sentences.',
45
+ // tier: 'small',
46
+ // params: {
47
+ // text: { type: 'string', required: true, description: 'Text to summarise.' },
48
+ // },
49
+ // async run({ text }: { text: string }) {
50
+ // const brain = useBrain()
51
+ // if (!brain) return { error: 'brain unavailable' }
52
+ // const { text: summary } = await brain.complete({
53
+ // prompt: `Summarise in 1-2 sentences:\n\n${text}`,
54
+ // })
55
+ // return { summary }
56
+ // },
57
+ // },
26
58
  }
@@ -4,7 +4,12 @@ name: {{.DisplayName}}
4
4
  category: space
5
5
  description: AI agent for the {{.DisplayName}} space
6
6
  maxIterations: 15
7
- tools: []
7
+ # Whitelist of tools the agent can call. Each action in src/actions.ts is
8
+ # exposed as `<space-id>.<actionName>`. Add yours here as you build them;
9
+ # space_list_actions + space_run_action remain available as the fallback
10
+ # discovery bridge, but explicit listing gives the model proper tool schemas.
11
+ tools:
12
+ - {{.ID}}.ping
8
13
  canInvokeAgents: []
9
14
  ---
10
15
 
@@ -12,12 +17,14 @@ You are Construct's {{.DisplayName}} agent. You help users work within the {{.Di
12
17
 
13
18
  ## Context
14
19
 
15
- Use space_list_actions to discover available actions for this space.
16
- Use space_run_action to execute actions.
20
+ Use the listed tools above first. If you need an action that isn't whitelisted,
21
+ call space_list_actions to discover what else this space exposes, then
22
+ space_run_action to invoke it.
23
+
17
24
  Do NOT call get_project_context — you work with space content, not project files.
18
25
 
19
26
  ## Behavior
20
27
 
21
- - Start by listing available actions to understand what you can do
28
+ - Start by understanding what the user wants in this space
22
29
  - Be concise and action-oriented
23
30
  - Focus on tasks relevant to this space
@@ -162,7 +162,7 @@ export const Task = defineModel('task', {
162
162
  labels: field.json(),
163
163
  assignee_id: field.string().index(),
164
164
  }, {
165
- scope: 'org', // partitions schema per organization
165
+ scopes: ['org'], // partitions schema per organization
166
166
  access: {
167
167
  read: access.authenticated(),
168
168
  create: access.authenticated(),
@@ -188,10 +188,12 @@ await tasks.remove(id)
188
188
 
189
189
  **Access helpers**: `authenticated()`, `owner()`, `admin()`, `member()`. `member()` requires org context — use `authenticated()` for org-scoped models that allow any logged-in member.
190
190
 
191
- **Scopes**:
192
- - `app` — single shared schema (rare)
193
- - `org` — partitioned by organization (most product spaces)
194
- - `project` — partitioned by project (dev-tooling spaces)
191
+ **Scopes** (must match `scopes` in space.manifest.json):
192
+ - `'app'` — per-user bucket (personal install)
193
+ - `'org'` — shared across the active organization (org install)
194
+ - both: `scopes: ['app', 'org']` — host picks bucket at install time
195
+
196
+ `'project'` was removed in SDK 1.0. Older docs that show it are stale.
195
197
 
196
198
  **Push models to backend after editing**: `construct graph push`.
197
199
 
@@ -219,7 +221,9 @@ await tasks.remove(id)
219
221
  `src/actions.ts` exports an `actions` object — each entry is exposed to the space's agent as a first-class tool.
220
222
 
221
223
  ```ts
222
- export const actions = {
224
+ import type { SpaceActions } from '@construct-space/sdk'
225
+
226
+ export const actions: SpaceActions = {
223
227
  createTask: {
224
228
  description: 'Create a task',
225
229
  params: {
@@ -231,6 +235,75 @@ export const actions = {
231
235
  }
232
236
  ```
233
237
 
238
+ ### Tier-routed brain calls (LLM from inside an action)
239
+
240
+ Actions can call the model mid-execution via `useBrain()`. The action picks
241
+ the **cost bucket** (small / medium / large), the **user** picks the slot
242
+ in Settings → LLM Providers. The host maps tier → provider + model.
243
+
244
+ ```ts
245
+ import { useBrain } from '@construct-space/sdk'
246
+ import type { SpaceActions } from '@construct-space/sdk'
247
+
248
+ export const actions: SpaceActions = {
249
+ summarizeThread: {
250
+ description: 'Summarise one email thread in 1-2 sentences.',
251
+ tier: 'small', // default for brain calls in `run`
252
+ params: { threadId: { type: 'string', required: true } },
253
+ async run({ threadId }) {
254
+ const brain = useBrain()
255
+ if (!brain) return { error: 'brain unavailable' }
256
+ const thread = await loadThread(threadId as string)
257
+ const { text } = await brain.complete({ prompt: `Summarise:\n${thread.body}` })
258
+ return { summary: text }
259
+ },
260
+ },
261
+
262
+ composeReply: {
263
+ description: 'Draft a polished reply to the given message.',
264
+ tier: 'large', // long-form writing → opus-class
265
+ params: {
266
+ original: { type: 'string', required: true },
267
+ style: { type: 'string', required: false, description: 'e.g. "warm", "formal"' },
268
+ },
269
+ async run({ original, style }) {
270
+ const brain = useBrain()
271
+ if (!brain) return { error: 'brain unavailable' }
272
+ const { text } = await brain.chat({
273
+ system: 'You write replies that feel like a thoughtful human wrote them.',
274
+ messages: [{ role: 'user', content: `${style ? `Tone: ${style}.\n\n` : ''}Reply to:\n${original}` }],
275
+ })
276
+ return { draft: text }
277
+ },
278
+ },
279
+ }
280
+ ```
281
+
282
+ Per-call overrides win: `brain.complete({ prompt, tier: 'large' })` upgrades
283
+ even a small-tier action when the user asked for "detailed".
284
+
285
+ **Permission required.** A space whose actions call brain must declare
286
+ `{{.ID}}:brain` in `permissions.catalog` and map each brain-using action
287
+ to it under `permissions.actions`. The user grants this at install time.
288
+
289
+ ```jsonc
290
+ // space.manifest.json
291
+ {
292
+ "permissions": {
293
+ "actions": {
294
+ "summarizeThread": "{{.ID}}:brain",
295
+ "composeReply": "{{.ID}}:brain"
296
+ },
297
+ "catalog": [
298
+ { "id": "{{.ID}}:brain", "label": "Use AI for summaries & drafts" }
299
+ ]
300
+ }
301
+ }
302
+ ```
303
+
304
+ Without the grant, `useBrain()` throws `BrainPermissionDenied` synchronously
305
+ on the first method call so the action can fall back gracefully.
306
+
234
307
  ### Wiring (REQUIRED — easy to miss)
235
308
 
236
309
  Each action becomes a tool named **`{spaceID}.{actionName}`** (dot, not underscore). The operator only pre-registers actions that are **explicitly whitelisted** in the agent config. An empty `tools: []` means the agent sees zero action tools and will report "unknown tool".
@@ -20,8 +20,9 @@ const hostExternals = [
20
20
  'dexie',
21
21
  'zod',
22
22
  '@construct-space/ui',
23
- '@construct/sdk',
24
23
  '@construct-space/sdk',
24
+ '@construct-space/graph',
25
+ '@construct/sdk', // legacy alias — keep during cutover
25
26
  ]
26
27
 
27
28
  function makeGlobals(externals: string[]): Record<string, string> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@construct-space/cli",
3
- "version": "1.7.7",
3
+ "version": "1.8.1",
4
4
  "description": "Construct CLI — scaffold, build, develop, and publish spaces",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,26 +1,58 @@
1
1
  /**
2
2
  * Space Actions — exposed to the AI agent via space_run_action.
3
3
  *
4
- * Each action: { description, params, run }.
5
- * - `params` describe agent-callable inputs (type, description, required).
6
- * - `run` receives the payload and returns any JSON-serialisable value.
4
+ * Each action: { description, params, run, tier? }.
5
+ * - `params` JSON-schema-ish input shape; each: { type, description?, required? }.
6
+ * - `run` receives the validated payload, returns any JSON-serialisable value.
7
+ * - `tier` (optional) default model bucket for useBrain() calls inside `run`:
8
+ * 'small' fast/cheap — summarisation, classification, short Q&A
9
+ * 'medium' balanced — general help, edits, structured output
10
+ * 'large' reasoning — long-form writing, deep code, planning
11
+ * The host resolves tier → provider+model via the user's tier config
12
+ * (Settings → LLM Providers). Per-call `brain.complete({ tier })`
13
+ * wins over the action default.
14
+ *
15
+ * Permission for useBrain():
16
+ * - Declare `{{.ID}}:brain` in `permissions.catalog` (space.manifest.json).
17
+ * - Add the action to `permissions.actions` mapping to that id, e.g.
18
+ * "permissions": {
19
+ * "actions": { "summarize": "{{.ID}}:brain" },
20
+ * "catalog": [{ "id": "{{.ID}}:brain", "label": "Use AI summaries" }]
21
+ * }
7
22
  *
8
23
  * Pair with @construct-space/graph for typed multi-tenant data:
9
24
  * import { useGraph } from '@construct-space/graph'
10
25
  * import { Item } from './models'
11
26
  * const items = useGraph(Item)
12
- *
13
- * listItems: {
14
- * description: 'List items',
15
- * params: { limit: { type: 'number', required: false } },
16
- * run: async (p: any) => ({ items: await items.find({ limit: p.limit ?? 50 }) }),
17
- * }
18
27
  */
19
28
 
20
- export const actions = {
29
+ import { useBrain } from '@construct-space/sdk'
30
+ import type { SpaceActions } from '@construct-space/sdk'
31
+
32
+ export const actions: SpaceActions = {
21
33
  ping: {
22
34
  description: 'Health check — returns pong with the space id.',
23
35
  params: {},
24
36
  run: () => ({ pong: true, space: '{{.ID}}' }),
25
37
  },
38
+
39
+ // Example: a tier-routed action. Delete or adapt for your space.
40
+ // Requires '{{.ID}}:brain' in permissions.catalog and a row in
41
+ // permissions.actions mapping 'summarize' to that id.
42
+ //
43
+ // summarize: {
44
+ // description: 'Summarise the given text in 1-2 sentences.',
45
+ // tier: 'small',
46
+ // params: {
47
+ // text: { type: 'string', required: true, description: 'Text to summarise.' },
48
+ // },
49
+ // async run({ text }: { text: string }) {
50
+ // const brain = useBrain()
51
+ // if (!brain) return { error: 'brain unavailable' }
52
+ // const { text: summary } = await brain.complete({
53
+ // prompt: `Summarise in 1-2 sentences:\n\n${text}`,
54
+ // })
55
+ // return { summary }
56
+ // },
57
+ // },
26
58
  }
@@ -4,7 +4,12 @@ name: {{.DisplayName}}
4
4
  category: space
5
5
  description: AI agent for the {{.DisplayName}} space
6
6
  maxIterations: 15
7
- tools: []
7
+ # Whitelist of tools the agent can call. Each action in src/actions.ts is
8
+ # exposed as `<space-id>.<actionName>`. Add yours here as you build them;
9
+ # space_list_actions + space_run_action remain available as the fallback
10
+ # discovery bridge, but explicit listing gives the model proper tool schemas.
11
+ tools:
12
+ - {{.ID}}.ping
8
13
  canInvokeAgents: []
9
14
  ---
10
15
 
@@ -12,12 +17,14 @@ You are Construct's {{.DisplayName}} agent. You help users work within the {{.Di
12
17
 
13
18
  ## Context
14
19
 
15
- Use space_list_actions to discover available actions for this space.
16
- Use space_run_action to execute actions.
20
+ Use the listed tools above first. If you need an action that isn't whitelisted,
21
+ call space_list_actions to discover what else this space exposes, then
22
+ space_run_action to invoke it.
23
+
17
24
  Do NOT call get_project_context — you work with space content, not project files.
18
25
 
19
26
  ## Behavior
20
27
 
21
- - Start by listing available actions to understand what you can do
28
+ - Start by understanding what the user wants in this space
22
29
  - Be concise and action-oriented
23
30
  - Focus on tasks relevant to this space
@@ -162,7 +162,7 @@ export const Task = defineModel('task', {
162
162
  labels: field.json(),
163
163
  assignee_id: field.string().index(),
164
164
  }, {
165
- scope: 'org', // partitions schema per organization
165
+ scopes: ['org'], // partitions schema per organization
166
166
  access: {
167
167
  read: access.authenticated(),
168
168
  create: access.authenticated(),
@@ -188,10 +188,12 @@ await tasks.remove(id)
188
188
 
189
189
  **Access helpers**: `authenticated()`, `owner()`, `admin()`, `member()`. `member()` requires org context — use `authenticated()` for org-scoped models that allow any logged-in member.
190
190
 
191
- **Scopes**:
192
- - `app` — single shared schema (rare)
193
- - `org` — partitioned by organization (most product spaces)
194
- - `project` — partitioned by project (dev-tooling spaces)
191
+ **Scopes** (must match `scopes` in space.manifest.json):
192
+ - `'app'` — per-user bucket (personal install)
193
+ - `'org'` — shared across the active organization (org install)
194
+ - both: `scopes: ['app', 'org']` — host picks bucket at install time
195
+
196
+ `'project'` was removed in SDK 1.0. Older docs that show it are stale.
195
197
 
196
198
  **Push models to backend after editing**: `construct graph push`.
197
199
 
@@ -219,7 +221,9 @@ await tasks.remove(id)
219
221
  `src/actions.ts` exports an `actions` object — each entry is exposed to the space's agent as a first-class tool.
220
222
 
221
223
  ```ts
222
- export const actions = {
224
+ import type { SpaceActions } from '@construct-space/sdk'
225
+
226
+ export const actions: SpaceActions = {
223
227
  createTask: {
224
228
  description: 'Create a task',
225
229
  params: {
@@ -231,6 +235,75 @@ export const actions = {
231
235
  }
232
236
  ```
233
237
 
238
+ ### Tier-routed brain calls (LLM from inside an action)
239
+
240
+ Actions can call the model mid-execution via `useBrain()`. The action picks
241
+ the **cost bucket** (small / medium / large), the **user** picks the slot
242
+ in Settings → LLM Providers. The host maps tier → provider + model.
243
+
244
+ ```ts
245
+ import { useBrain } from '@construct-space/sdk'
246
+ import type { SpaceActions } from '@construct-space/sdk'
247
+
248
+ export const actions: SpaceActions = {
249
+ summarizeThread: {
250
+ description: 'Summarise one email thread in 1-2 sentences.',
251
+ tier: 'small', // default for brain calls in `run`
252
+ params: { threadId: { type: 'string', required: true } },
253
+ async run({ threadId }) {
254
+ const brain = useBrain()
255
+ if (!brain) return { error: 'brain unavailable' }
256
+ const thread = await loadThread(threadId as string)
257
+ const { text } = await brain.complete({ prompt: `Summarise:\n${thread.body}` })
258
+ return { summary: text }
259
+ },
260
+ },
261
+
262
+ composeReply: {
263
+ description: 'Draft a polished reply to the given message.',
264
+ tier: 'large', // long-form writing → opus-class
265
+ params: {
266
+ original: { type: 'string', required: true },
267
+ style: { type: 'string', required: false, description: 'e.g. "warm", "formal"' },
268
+ },
269
+ async run({ original, style }) {
270
+ const brain = useBrain()
271
+ if (!brain) return { error: 'brain unavailable' }
272
+ const { text } = await brain.chat({
273
+ system: 'You write replies that feel like a thoughtful human wrote them.',
274
+ messages: [{ role: 'user', content: `${style ? `Tone: ${style}.\n\n` : ''}Reply to:\n${original}` }],
275
+ })
276
+ return { draft: text }
277
+ },
278
+ },
279
+ }
280
+ ```
281
+
282
+ Per-call overrides win: `brain.complete({ prompt, tier: 'large' })` upgrades
283
+ even a small-tier action when the user asked for "detailed".
284
+
285
+ **Permission required.** A space whose actions call brain must declare
286
+ `{{.ID}}:brain` in `permissions.catalog` and map each brain-using action
287
+ to it under `permissions.actions`. The user grants this at install time.
288
+
289
+ ```jsonc
290
+ // space.manifest.json
291
+ {
292
+ "permissions": {
293
+ "actions": {
294
+ "summarizeThread": "{{.ID}}:brain",
295
+ "composeReply": "{{.ID}}:brain"
296
+ },
297
+ "catalog": [
298
+ { "id": "{{.ID}}:brain", "label": "Use AI for summaries & drafts" }
299
+ ]
300
+ }
301
+ }
302
+ ```
303
+
304
+ Without the grant, `useBrain()` throws `BrainPermissionDenied` synchronously
305
+ on the first method call so the action can fall back gracefully.
306
+
234
307
  ### Wiring (REQUIRED — easy to miss)
235
308
 
236
309
  Each action becomes a tool named **`{spaceID}.{actionName}`** (dot, not underscore). The operator only pre-registers actions that are **explicitly whitelisted** in the agent config. An empty `tools: []` means the agent sees zero action tools and will report "unknown tool".
@@ -20,8 +20,9 @@ const hostExternals = [
20
20
  'dexie',
21
21
  'zod',
22
22
  '@construct-space/ui',
23
- '@construct/sdk',
24
23
  '@construct-space/sdk',
24
+ '@construct-space/graph',
25
+ '@construct/sdk', // legacy alias — keep during cutover
25
26
  ]
26
27
 
27
28
  function makeGlobals(externals: string[]): Record<string, string> {