@standards/cli 1.0.0-alpha.408 → 1.0.0-alpha.410

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 (3) hide show
  1. package/README.md +5 -28
  2. package/dist/bin.mjs +301 -974
  3. package/package.json +4 -6
package/README.md CHANGED
@@ -3,35 +3,12 @@
3
3
  The Standards CLI connects a terminal to a Standards instance. See the public
4
4
  [CLI guide](../../content/docs/13-cli/index.mdx) for installation and the full command surface.
5
5
 
6
- ## Run this computer as a paired device
6
+ ## Devices
7
7
 
8
- Remote device execution is an optional server capability. Once an operator has enabled it,
9
- create a named profile, pair the current computer, and keep the foreground worker running:
10
-
11
- ```bash
12
- standards login --name production --url https://standards.example/v1 --key "$STANDARDS_API_KEY"
13
- standards device pair --name "Build Mac"
14
- standards device serve
15
- ```
16
-
17
- The worker supports macOS and Linux and makes outbound HTTPS requests only. It executes each
18
- approved command in the foreground using the user's local account and permissions. There is no
19
- privilege escalation, background service installation, PTY, file transfer, or GUI control.
20
-
21
- Commands time out after 10 minutes by default and may not exceed 60 minutes. Standard output and
22
- standard error are each limited to 30,000 characters. Stop the worker with `Ctrl+C`; inspect it
23
- with `standards device status`; revoke its credential with `standards device revoke`.
24
-
25
- Interactive harnesses are outside the V0 contract. Invoke installed tools in their
26
- non-interactive mode, for example:
27
-
28
- ```bash
29
- codex exec --json "Run the repository checks"
30
- claude -p "Summarize the failing tests"
31
- ```
32
-
33
- Every agent command targeting a device requires explicit user approval. Calls to `exec_command`
34
- without a `deviceId` continue to use the agent sandbox.
8
+ The CLI no longer runs a device worker. A machine your agents run commands on is enrolled with
9
+ the `standards-device` worker and an enrollment token from Settings → Devices (**Enroll your
10
+ computer** for your own machine, **Enroll machines** for a managed fleet): see the
11
+ [device fleet guide](../../content/docs/10-agents/07-device-fleet.mdx).
35
12
 
36
13
  ## Move to the `@standards` scope
37
14
 
package/dist/bin.mjs CHANGED
@@ -5,22 +5,20 @@ import { createRequire } from "node:module";
5
5
  import { styleText } from "node:util";
6
6
  import { BundleConflictError, StandardsRequestError, createTransport, syncBundle } from "@standards/client";
7
7
  import { Command } from "commander";
8
- import { BUNDLE_LOCALES, COUNT_MODES, DEVICE_CHANNEL_WORKER_CLOSE_PROTOCOL_VIOLATION, MEETING_RESPONSES, RECORDING_OVERRIDES, RECORD_ACCESS_ROLES, RESOURCE_VISIBILITIES, SchemaError, SchemaErrorCode, ValidationError, canonicalStringify, computeReconnectDelay, computeRetryDelay, deviceChannelUrl, diffSchemaState, formatByteSize, parseRecordAccessRole, parseSchemaState, parseServerFrame, schemaStateSourcePayload } from "@standards/schema";
8
+ import { BUNDLE_LOCALES, COUNT_MODES, MEETING_RESPONSES, RECORDING_OVERRIDES, RECORD_ACCESS_ROLES, RESOURCE_VISIBILITIES, ValidationError, canonicalStringify, diffSchemaState, formatByteSize, parseRecordAccessRole, parseSchemaState, schemaStateSourcePayload } from "@standards/schema";
9
9
  import Table from "cli-table3";
10
10
  import { createAppClient } from "@standards/client/app";
11
- import { constants, createWriteStream } from "node:fs";
12
- import { access, chmod, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
13
- import { basename, dirname, isAbsolute, join } from "node:path";
14
- import { homedir, hostname } from "node:os";
15
- import { createHash, randomUUID } from "node:crypto";
16
- import { WebSocket } from "ws";
17
- import { spawn } from "node:child_process";
18
- import { StringDecoder } from "node:string_decoder";
11
+ import { createWriteStream } from "node:fs";
12
+ import { chmod, mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
13
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
14
+ import { createHash } from "node:crypto";
19
15
  import { Readable } from "node:stream";
20
16
  import { pipeline } from "node:stream/promises";
21
17
  import { buildOperationPath, recordsOperations } from "@standards/schema/api";
22
18
  import { stdin, stdout } from "node:process";
23
19
  import { createInterface } from "node:readline/promises";
20
+ import { homedir } from "node:os";
21
+ import { isSafeSkillFilePath, skillFileText } from "@standards/constants";
24
22
  //#region src/output.ts
25
23
  const MAX_CELL_WIDTH = 50;
26
24
  function truncate(value, maxLength) {
@@ -366,968 +364,6 @@ function registerConnectorsCommand(program) {
366
364
  });
367
365
  }
368
366
  //#endregion
369
- //#region src/config.ts
370
- const DEFAULT_API_URL = "http://localhost:4100/v1";
371
- const ENV_PROFILE_NAME = "sandbox";
372
- function getDefaultApiUrl() {
373
- return DEFAULT_API_URL;
374
- }
375
- function getConfigPath() {
376
- const configDir = process.env.STANDARDS_CONFIG_DIR ?? join(homedir(), ".standards");
377
- return join(configDir, "config.json");
378
- }
379
- async function readConfig() {
380
- try {
381
- const raw = await readFile(getConfigPath(), "utf8");
382
- const parsed = JSON.parse(raw);
383
- return {
384
- ...parsed.installationId ? { installationId: parsed.installationId } : {},
385
- currentProfile: parsed.currentProfile,
386
- profiles: parsed.profiles ?? {}
387
- };
388
- } catch (error) {
389
- if (error instanceof Error && "code" in error && error.code === "ENOENT") return { profiles: {} };
390
- throw error;
391
- }
392
- }
393
- async function writeConfig(config) {
394
- const path = getConfigPath();
395
- const directory = dirname(path);
396
- await mkdir(directory, {
397
- recursive: true,
398
- mode: 448
399
- });
400
- await chmod(directory, 448);
401
- await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
402
- await chmod(path, 384);
403
- }
404
- function normalizeApiOrigin(apiUrl) {
405
- try {
406
- return new URL(apiUrl).origin;
407
- } catch {
408
- return apiUrl.replace(/\/+$/, "");
409
- }
410
- }
411
- async function upsertProfile(input) {
412
- const config = await readConfig();
413
- const existingProfile = config.profiles[input.name];
414
- const device = existingProfile?.device && normalizeApiOrigin(existingProfile.apiUrl) === normalizeApiOrigin(input.apiUrl) ? existingProfile.device : void 0;
415
- config.profiles[input.name] = {
416
- apiUrl: input.apiUrl,
417
- apiKey: input.apiKey,
418
- ...device ? { device } : {}
419
- };
420
- config.currentProfile = input.name;
421
- await writeConfig(config);
422
- }
423
- async function getOrCreateInstallationId() {
424
- const config = await readConfig();
425
- if (config.installationId) return config.installationId;
426
- const installationId = randomUUID();
427
- config.installationId = installationId;
428
- await writeConfig(config);
429
- return installationId;
430
- }
431
- async function getStoredDeviceCredential(profileName) {
432
- return (await readConfig()).profiles[profileName]?.device ?? null;
433
- }
434
- async function storeDeviceCredential(profileName, credential) {
435
- const config = await readConfig();
436
- const profile = config.profiles[profileName];
437
- if (!profile) return false;
438
- profile.device = credential;
439
- await writeConfig(config);
440
- return true;
441
- }
442
- async function removeDeviceCredential(profileName) {
443
- const config = await readConfig();
444
- const profile = config.profiles[profileName];
445
- if (!profile?.device) return false;
446
- profile.device = void 0;
447
- await writeConfig(config);
448
- return true;
449
- }
450
- async function setCurrentProfile(name) {
451
- const config = await readConfig();
452
- if (!config.profiles[name]) throw new Error(`Unknown Standards instance "${name}"`);
453
- config.currentProfile = name;
454
- await writeConfig(config);
455
- }
456
- async function removeProfile(name) {
457
- const config = await readConfig();
458
- const profileName = name ?? config.currentProfile;
459
- if (!profileName) return;
460
- delete config.profiles[profileName];
461
- if (config.currentProfile === profileName) config.currentProfile = void 0;
462
- await writeConfig(config);
463
- }
464
- async function listProfiles() {
465
- const config = await readConfig();
466
- const profiles = Object.entries(config.profiles).sort(([a], [b]) => a.localeCompare(b)).map(([name, profile]) => ({
467
- name,
468
- apiUrl: profile.apiUrl,
469
- current: config.currentProfile === name
470
- }));
471
- const envProfile = getEnvProfile();
472
- if (envProfile) profiles.push({
473
- name: ENV_PROFILE_NAME,
474
- apiUrl: envProfile.apiUrl,
475
- current: !config.currentProfile,
476
- source: "env"
477
- });
478
- return profiles;
479
- }
480
- async function getActiveProfile() {
481
- const config = await readConfig();
482
- if (!config.currentProfile) {
483
- const envProfile = getEnvProfile();
484
- return envProfile ? {
485
- name: ENV_PROFILE_NAME,
486
- ...envProfile
487
- } : null;
488
- }
489
- const profile = config.profiles[config.currentProfile];
490
- if (!profile) return null;
491
- return {
492
- name: config.currentProfile,
493
- ...profile
494
- };
495
- }
496
- function getEnvProfile() {
497
- if (!process.env.STANDARDS_API_KEY) return null;
498
- return {
499
- apiUrl: process.env.STANDARDS_API_URL ?? DEFAULT_API_URL,
500
- apiKey: process.env.STANDARDS_API_KEY
501
- };
502
- }
503
- async function resolveCliConfig(options) {
504
- if (options.apiUrl || options.apiKey) return {
505
- apiUrl: options.apiUrl ?? process.env.STANDARDS_API_URL ?? DEFAULT_API_URL,
506
- apiKey: options.apiKey ?? process.env.STANDARDS_API_KEY
507
- };
508
- const config = await readConfig();
509
- const profileName = options.instance ?? config.currentProfile;
510
- if (profileName) {
511
- const profile = config.profiles[profileName];
512
- if (!profile) throw new Error(`Unknown Standards instance "${profileName}"`);
513
- return {
514
- apiUrl: profile.apiUrl,
515
- apiKey: profile.apiKey,
516
- profileName
517
- };
518
- }
519
- return {
520
- apiUrl: process.env.STANDARDS_API_URL ?? DEFAULT_API_URL,
521
- apiKey: process.env.STANDARDS_API_KEY
522
- };
523
- }
524
- //#endregion
525
- //#region src/device/worker-client.ts
526
- /** How long a connection may wait for `ready` after sending `authenticate`. */
527
- const REQUEST_TIMEOUT_MS = 3e4;
528
- /** The code the SERVER closes an authenticated-but-not-ready socket with. */
529
- const SERVER_POLICY_VIOLATION_CLOSE_CODE = 1008;
530
- const NORMAL_CLOSE_CODE = 1e3;
531
- const EMPTY_SHELL_COMMAND_REFUSED = "Shell command frame carries no command";
532
- var DeviceWorkerRequestError = class extends SchemaError {
533
- constructor(statusCode, message, code = SchemaErrorCode.REPOSITORY_QUERY_FAILED) {
534
- super(message, code, { statusCode });
535
- this.statusCode = statusCode;
536
- this.name = "DeviceWorkerRequestError";
537
- }
538
- };
539
- var DeviceCredentialRejectedError = class extends DeviceWorkerRequestError {
540
- constructor() {
541
- super(401, "Device credential rejected; run standards device pair again", SchemaErrorCode.ACCESS_DENIED);
542
- this.name = "DeviceCredentialRejectedError";
543
- }
544
- };
545
- /** The lease poll's own curve: 250 ms doubling up to 10 s, well under the channel's. */
546
- const LEASE_RETRY = {
547
- initialMs: 250,
548
- maxMs: 1e4
549
- };
550
- function computeRetryDelay$1(attempt, random = Math.random) {
551
- return computeRetryDelay(attempt, random, LEASE_RETRY);
552
- }
553
- function abortError() {
554
- return new DOMException("Aborted", "AbortError");
555
- }
556
- function isAbortError$1(error) {
557
- return error instanceof DOMException && error.name === "AbortError";
558
- }
559
- function sleepWithAbort(milliseconds, signal) {
560
- if (signal?.aborted) return Promise.reject(abortError());
561
- return new Promise((resolve, reject) => {
562
- function abort() {
563
- clearTimeout(timer);
564
- reject(abortError());
565
- }
566
- const timer = setTimeout(() => {
567
- signal?.removeEventListener("abort", abort);
568
- resolve();
569
- }, milliseconds);
570
- signal?.addEventListener("abort", abort, { once: true });
571
- });
572
- }
573
- function withAbort(promise, signal) {
574
- if (!signal) return promise;
575
- if (signal.aborted) return Promise.reject(abortError());
576
- return new Promise((resolve, reject) => {
577
- const abort = () => reject(abortError());
578
- signal.addEventListener("abort", abort, { once: true });
579
- promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
580
- });
581
- }
582
- function transmit(socket, frame) {
583
- return new Promise((resolve, reject) => {
584
- socket.send(JSON.stringify(frame), (error) => {
585
- if (error) reject(new DeviceWorkerRequestError(0, "Device channel send failed"));
586
- else resolve();
587
- });
588
- });
589
- }
590
- function leasedCommand(frame, command) {
591
- return {
592
- id: frame.id,
593
- leaseToken: frame.leaseToken,
594
- intent: frame.intent,
595
- command,
596
- timeoutMs: frame.timeoutMs,
597
- ...typeof frame.cwd === "string" ? { cwd: frame.cwd } : {}
598
- };
599
- }
600
- /**
601
- * One WebSocket to the device channel. The first frame authenticates the
602
- * device; commands are queued as they arrive and handed to `lease()`.
603
- * A dropped connection reconnects with backoff; a credential rejection
604
- * (`revoked`, or close 1008 before `ready`) is terminal.
605
- */
606
- var DeviceWorkerClient = class {
607
- constructor(options) {
608
- this.lifecycle = new AbortController();
609
- this.queue = [];
610
- this.waiters = /* @__PURE__ */ new Set();
611
- this.cancellations = /* @__PURE__ */ new Map();
612
- this.socket = null;
613
- this.ready = false;
614
- this.connection = null;
615
- this.rejection = null;
616
- this.closed = false;
617
- this.url = deviceChannelUrl(options.apiUrl);
618
- this.credential = `Device ${options.token}`;
619
- this.sleep = options.sleep ?? sleepWithAbort;
620
- this.random = options.random ?? Math.random;
621
- this.now = options.now ?? Date.now;
622
- }
623
- /** Stops reconnecting and closes the socket; pending and later leases reject with an AbortError. */
624
- close() {
625
- this.closed = true;
626
- this.lifecycle.abort();
627
- this.socket?.close(NORMAL_CLOSE_CODE);
628
- this.failWaiters(abortError());
629
- }
630
- lease(signal) {
631
- if (this.rejection) return Promise.reject(this.rejection);
632
- if (this.closed || signal?.aborted) return Promise.reject(abortError());
633
- this.connection ??= this.maintainConnection().catch((error) => this.failWaiters(error));
634
- const queued = this.queue.shift();
635
- if (queued) return Promise.resolve(queued);
636
- return new Promise((resolve, reject) => {
637
- const settle = () => {
638
- this.waiters.delete(waiter);
639
- signal?.removeEventListener("abort", abort);
640
- };
641
- const waiter = {
642
- resolve: (command) => {
643
- settle();
644
- resolve(command);
645
- },
646
- reject: (error) => {
647
- settle();
648
- reject(error);
649
- }
650
- };
651
- const abort = () => waiter.reject(abortError());
652
- signal?.addEventListener("abort", abort, { once: true });
653
- this.waiters.add(waiter);
654
- });
655
- }
656
- async markRunning(commandId, leaseToken) {
657
- await this.send({
658
- type: "running",
659
- id: commandId,
660
- leaseToken
661
- });
662
- }
663
- async heartbeat(commandId, leaseToken, signal) {
664
- const cancelled = this.cancellations.get(commandId);
665
- if (cancelled) return {
666
- cancel: true,
667
- reason: cancelled
668
- };
669
- await withAbort(this.send({
670
- type: "heartbeat",
671
- id: commandId,
672
- leaseToken
673
- }), signal);
674
- const reason = this.cancellations.get(commandId);
675
- return reason ? {
676
- cancel: true,
677
- reason
678
- } : { cancel: false };
679
- }
680
- async output(commandId, leaseToken, stdout, stderr) {
681
- if (!(stdout || stderr)) return;
682
- await this.send({
683
- type: "output",
684
- id: commandId,
685
- leaseToken,
686
- ...stdout ? { stdout } : {},
687
- ...stderr ? { stderr } : {}
688
- });
689
- }
690
- async complete(commandId, leaseToken, result) {
691
- await this.send({
692
- type: "complete",
693
- id: commandId,
694
- leaseToken,
695
- result: {
696
- stdout: result.stdout,
697
- stderr: result.stderr,
698
- exitCode: result.exitCode,
699
- success: result.success,
700
- ...result.errorCode ? { errorCode: result.errorCode } : {}
701
- }
702
- });
703
- this.cancellations.delete(commandId);
704
- }
705
- send(frame) {
706
- const socket = this.socket;
707
- if (!(socket && this.ready && socket.readyState === WebSocket.OPEN)) return Promise.reject(new DeviceWorkerRequestError(0, "Device channel is not connected"));
708
- return transmit(socket, frame);
709
- }
710
- reject(error) {
711
- this.rejection = error;
712
- this.failWaiters(error);
713
- }
714
- failWaiters(error) {
715
- for (const waiter of [...this.waiters]) waiter.reject(error);
716
- }
717
- deliver(command) {
718
- const [waiter] = this.waiters;
719
- if (waiter) waiter.resolve(command);
720
- else this.queue.push(command);
721
- }
722
- async maintainConnection() {
723
- const signal = this.lifecycle.signal;
724
- let attempt = 0;
725
- let lastDelay = 0;
726
- while (!(signal.aborted || this.rejection)) {
727
- const { ready, readyAt } = await this.connectOnce();
728
- if (signal.aborted || this.rejection) return;
729
- if (ready && readyAt !== null && this.now() - readyAt > lastDelay) attempt = 0;
730
- lastDelay = computeReconnectDelay(attempt, this.random);
731
- try {
732
- await this.sleep(lastDelay, signal);
733
- } catch (error) {
734
- if (isAbortError$1(error)) return;
735
- throw error;
736
- }
737
- attempt += 1;
738
- }
739
- }
740
- /** Opens one socket and resolves once it has closed, telling whether and when `ready` was seen. */
741
- connectOnce() {
742
- return new Promise((resolve) => {
743
- const signal = this.lifecycle.signal;
744
- const socket = new WebSocket(this.url);
745
- let authenticated = false;
746
- let ready = false;
747
- let readyAt = null;
748
- const stop = () => socket.close(NORMAL_CLOSE_CODE);
749
- const readyTimer = setTimeout(stop, REQUEST_TIMEOUT_MS);
750
- signal.addEventListener("abort", stop, { once: true });
751
- this.socket = socket;
752
- socket.on("open", () => {
753
- authenticated = true;
754
- transmit(socket, {
755
- type: "authenticate",
756
- credential: this.credential
757
- }).catch(stop);
758
- });
759
- socket.on("error", () => void 0);
760
- socket.on("message", (data) => {
761
- let frame;
762
- try {
763
- frame = parseServerFrame(data.toString());
764
- } catch {
765
- socket.close(DEVICE_CHANNEL_WORKER_CLOSE_PROTOCOL_VIOLATION, "DEVICE_CHANNEL_PROTOCOL");
766
- return;
767
- }
768
- if (frame.type === "ready") {
769
- clearTimeout(readyTimer);
770
- ready = true;
771
- readyAt = this.now();
772
- this.ready = true;
773
- return;
774
- }
775
- this.handleFrame(socket, frame);
776
- });
777
- socket.on("close", (code) => {
778
- clearTimeout(readyTimer);
779
- signal.removeEventListener("abort", stop);
780
- this.socket = null;
781
- this.ready = false;
782
- if (code === SERVER_POLICY_VIOLATION_CLOSE_CODE && authenticated && !ready) this.reject(new DeviceCredentialRejectedError());
783
- resolve({
784
- ready,
785
- readyAt
786
- });
787
- });
788
- });
789
- }
790
- handleFrame(socket, frame) {
791
- switch (frame.type) {
792
- case "command":
793
- this.receiveCommand(socket, frame);
794
- return;
795
- case "cancel":
796
- this.cancellations.set(frame.id, frame.reason);
797
- return;
798
- case "reauthenticate":
799
- transmit(socket, {
800
- type: "authenticate",
801
- credential: this.credential
802
- }).catch(() => socket.close(NORMAL_CLOSE_CODE));
803
- return;
804
- case "revoked":
805
- this.reject(new DeviceCredentialRejectedError());
806
- socket.close(NORMAL_CLOSE_CODE);
807
- return;
808
- case "ping":
809
- transmit(socket, { type: "pong" }).catch(() => socket.close(NORMAL_CLOSE_CODE));
810
- return;
811
- }
812
- }
813
- receiveCommand(socket, frame) {
814
- if (frame.command === void 0) {
815
- this.refuse(socket, frame, EMPTY_SHELL_COMMAND_REFUSED);
816
- return;
817
- }
818
- this.deliver(leasedCommand(frame, frame.command));
819
- }
820
- /** Completes a command the shell worker cannot run, without handing it to the loop. */
821
- refuse(socket, frame, stderr) {
822
- const { id, leaseToken } = frame;
823
- transmit(socket, {
824
- type: "running",
825
- id,
826
- leaseToken
827
- }).then(() => transmit(socket, {
828
- type: "complete",
829
- id,
830
- leaseToken,
831
- result: {
832
- stdout: "",
833
- stderr,
834
- exitCode: 1,
835
- success: false,
836
- errorCode: "execution_failed"
837
- }
838
- })).catch(() => void 0);
839
- }
840
- };
841
- //#endregion
842
- //#region src/device/process-runner.ts
843
- const OUTPUT_LIMIT = 3e4;
844
- const TERMINATION_GRACE_MS = 5e3;
845
- const TERMINATION_POLL_MS = 50;
846
- var BoundedTextBuffer = class {
847
- constructor() {
848
- this.decoder = new StringDecoder("utf8");
849
- this.decoderFlushed = false;
850
- this.value = "";
851
- this.truncated = false;
852
- }
853
- append(chunk) {
854
- if (this.truncated) return;
855
- const text = typeof chunk === "string" ? chunk : this.decoder.write(Buffer.from(chunk));
856
- this.appendText(text);
857
- }
858
- appendText(text) {
859
- const remaining = OUTPUT_LIMIT - this.value.length;
860
- if (text.length <= remaining) {
861
- this.value += text;
862
- return;
863
- }
864
- this.value += text.slice(0, Math.max(remaining, 0));
865
- this.truncated = true;
866
- }
867
- toString() {
868
- if (!this.decoderFlushed) {
869
- this.decoderFlushed = true;
870
- this.appendText(this.decoder.end());
871
- }
872
- return this.truncated ? `${this.value}\n... output truncated to ${OUTPUT_LIMIT} chars` : this.value;
873
- }
874
- };
875
- function isMissingProcess(error) {
876
- return error instanceof Error && "code" in error && error.code === "ESRCH";
877
- }
878
- function signalProcessGroup(pid, signal, kill) {
879
- try {
880
- kill(-pid, signal);
881
- return true;
882
- } catch (error) {
883
- if (isMissingProcess(error)) return false;
884
- throw error;
885
- }
886
- }
887
- function beginProcessGroupTermination(pid, deps = {}) {
888
- const kill = deps.kill ?? process.kill.bind(process);
889
- const setTimer = deps.setTimer ?? setTimeout;
890
- const clearTimer = deps.clearTimer ?? clearTimeout;
891
- return new Promise((resolve, reject) => {
892
- let settled = false;
893
- let escalationTimer;
894
- let pollTimer;
895
- function clearTimers() {
896
- if (escalationTimer) clearTimer(escalationTimer);
897
- if (pollTimer) clearTimer(pollTimer);
898
- }
899
- function finish() {
900
- if (settled) return;
901
- settled = true;
902
- clearTimers();
903
- resolve();
904
- }
905
- function fail(error) {
906
- if (settled) return;
907
- settled = true;
908
- clearTimers();
909
- reject(error);
910
- }
911
- function poll() {
912
- if (settled) return;
913
- try {
914
- if (!signalProcessGroup(pid, 0, kill)) {
915
- finish();
916
- return;
917
- }
918
- pollTimer = setTimer(poll, TERMINATION_POLL_MS);
919
- } catch (error) {
920
- fail(error);
921
- }
922
- }
923
- try {
924
- if (!signalProcessGroup(pid, "SIGTERM", kill)) {
925
- finish();
926
- return;
927
- }
928
- escalationTimer = setTimer(() => {
929
- try {
930
- if (!signalProcessGroup(pid, 0, kill)) {
931
- finish();
932
- return;
933
- }
934
- signalProcessGroup(pid, "SIGKILL", kill);
935
- } catch (error) {
936
- fail(error);
937
- }
938
- }, TERMINATION_GRACE_MS);
939
- pollTimer = setTimer(poll, TERMINATION_POLL_MS);
940
- } catch (error) {
941
- fail(error);
942
- }
943
- });
944
- }
945
- async function resolveShell(shell = process.env.SHELL) {
946
- if (!(shell && isAbsolute(shell))) return "/bin/sh";
947
- try {
948
- if (!(await stat(shell)).isFile()) return "/bin/sh";
949
- await access(shell, constants.X_OK);
950
- return shell;
951
- } catch {
952
- return "/bin/sh";
953
- }
954
- }
955
- function terminalResult(reason, stdout, stderr) {
956
- return {
957
- stdout: stdout.toString(),
958
- stderr: stderr.toString(),
959
- exitCode: reason === "timed_out" ? 124 : 130,
960
- success: false,
961
- errorCode: reason
962
- };
963
- }
964
- async function runDeviceProcess(input) {
965
- if (input.signal?.aborted) return {
966
- stdout: "",
967
- stderr: "",
968
- exitCode: 130,
969
- success: false,
970
- errorCode: "cancelled"
971
- };
972
- const shell = await resolveShell();
973
- if (input.signal?.aborted) return {
974
- stdout: "",
975
- stderr: "",
976
- exitCode: 130,
977
- success: false,
978
- errorCode: "cancelled"
979
- };
980
- const stdout = new BoundedTextBuffer();
981
- const stderr = new BoundedTextBuffer();
982
- return new Promise((resolve) => {
983
- let settled = false;
984
- let terminationReason;
985
- let termination;
986
- let child;
987
- function finish(result) {
988
- if (settled) return;
989
- settled = true;
990
- clearTimeout(timeout);
991
- input.signal?.removeEventListener("abort", cancel);
992
- resolve(result);
993
- }
994
- function finishTerminationFailure(error) {
995
- finish({
996
- stdout: stdout.toString(),
997
- stderr: error instanceof Error ? error.message : String(error),
998
- exitCode: 1,
999
- success: false,
1000
- errorCode: "execution_failed"
1001
- });
1002
- }
1003
- function terminate(reason) {
1004
- if (settled || terminationReason || !child?.pid) return;
1005
- terminationReason = reason;
1006
- termination = beginProcessGroupTermination(child.pid);
1007
- termination.catch(finishTerminationFailure);
1008
- }
1009
- function cancel() {
1010
- terminate("cancelled");
1011
- }
1012
- const timeout = setTimeout(() => terminate("timed_out"), input.timeoutMs);
1013
- try {
1014
- child = spawn(shell, ["-lc", input.command], {
1015
- cwd: input.cwd,
1016
- env: process.env,
1017
- detached: true,
1018
- stdio: [
1019
- "ignore",
1020
- "pipe",
1021
- "pipe"
1022
- ]
1023
- });
1024
- } catch (error) {
1025
- finish({
1026
- stdout: "",
1027
- stderr: error instanceof Error ? error.message : String(error),
1028
- exitCode: 1,
1029
- success: false,
1030
- errorCode: "execution_failed"
1031
- });
1032
- return;
1033
- }
1034
- input.signal?.addEventListener("abort", cancel, { once: true });
1035
- if (input.signal?.aborted) cancel();
1036
- const spawned = child;
1037
- spawned.stdout.on("data", (chunk) => stdout.append(chunk));
1038
- spawned.stderr.on("data", (chunk) => stderr.append(chunk));
1039
- spawned.once("error", (error) => {
1040
- finish({
1041
- stdout: stdout.toString(),
1042
- stderr: error.message,
1043
- exitCode: 1,
1044
- success: false,
1045
- errorCode: "execution_failed"
1046
- });
1047
- });
1048
- spawned.once("close", (code) => {
1049
- if (terminationReason) {
1050
- const reason = terminationReason;
1051
- (termination ?? Promise.resolve()).then(() => finish(terminalResult(reason, stdout, stderr)), finishTerminationFailure);
1052
- return;
1053
- }
1054
- const exitCode = code ?? 1;
1055
- finish({
1056
- stdout: stdout.toString(),
1057
- stderr: stderr.toString(),
1058
- exitCode,
1059
- success: exitCode === 0,
1060
- ...code === null ? { errorCode: "execution_failed" } : {}
1061
- });
1062
- });
1063
- });
1064
- }
1065
- //#endregion
1066
- //#region src/device/worker-loop.ts
1067
- const HEARTBEAT_INTERVAL_MS = 2e3;
1068
- function isAbortError(error) {
1069
- return error instanceof DOMException && error.name === "AbortError";
1070
- }
1071
- /** The channel client reconnects on its own; only a transport failure (status 0) is retried here. */
1072
- function isRetryableIdleError(error) {
1073
- return error instanceof DeviceWorkerRequestError && error.statusCode === 0;
1074
- }
1075
- function isLeaseConflict(error) {
1076
- return error instanceof DeviceWorkerRequestError && error.statusCode === 409;
1077
- }
1078
- async function executeLease(command, input, runProcess, sleep) {
1079
- try {
1080
- await input.client.markRunning(command.id, command.leaseToken);
1081
- } catch (error) {
1082
- if (error instanceof DeviceCredentialRejectedError) throw error;
1083
- return;
1084
- }
1085
- input.onEvent?.({
1086
- type: "running",
1087
- commandId: command.id,
1088
- intent: command.intent
1089
- });
1090
- const commandController = new AbortController();
1091
- const heartbeatController = new AbortController();
1092
- let completionAllowed = true;
1093
- let heartbeatFailure;
1094
- function abortCommand() {
1095
- commandController.abort();
1096
- }
1097
- input.signal.addEventListener("abort", abortCommand, { once: true });
1098
- const processPromise = runProcess({
1099
- command: command.command,
1100
- cwd: command.cwd,
1101
- timeoutMs: command.timeoutMs,
1102
- signal: commandController.signal
1103
- });
1104
- const heartbeatPromise = (async () => {
1105
- while (!heartbeatController.signal.aborted) try {
1106
- await sleep(HEARTBEAT_INTERVAL_MS, heartbeatController.signal);
1107
- const heartbeat = await input.client.heartbeat(command.id, command.leaseToken, heartbeatController.signal);
1108
- if (heartbeat.cancel) {
1109
- if (heartbeat.reason === "revoked") completionAllowed = false;
1110
- commandController.abort();
1111
- return;
1112
- }
1113
- } catch (error) {
1114
- if (heartbeatController.signal.aborted && isAbortError(error)) return;
1115
- heartbeatFailure = error;
1116
- completionAllowed = false;
1117
- commandController.abort();
1118
- return;
1119
- }
1120
- })();
1121
- let result;
1122
- try {
1123
- result = await processPromise;
1124
- } catch (error) {
1125
- result = {
1126
- stdout: "",
1127
- stderr: error instanceof Error ? error.message : String(error),
1128
- exitCode: 1,
1129
- success: false,
1130
- errorCode: "execution_failed"
1131
- };
1132
- } finally {
1133
- heartbeatController.abort();
1134
- await heartbeatPromise;
1135
- input.signal.removeEventListener("abort", abortCommand);
1136
- }
1137
- if (heartbeatFailure instanceof DeviceCredentialRejectedError) throw heartbeatFailure;
1138
- if (!completionAllowed || isLeaseConflict(heartbeatFailure)) return;
1139
- try {
1140
- await input.client.output(command.id, command.leaseToken, result.stdout, result.stderr);
1141
- await input.client.complete(command.id, command.leaseToken, result);
1142
- } catch (error) {
1143
- if (error instanceof DeviceCredentialRejectedError) throw error;
1144
- return;
1145
- }
1146
- input.onEvent?.({
1147
- type: "completed",
1148
- commandId: command.id,
1149
- exitCode: result.exitCode
1150
- });
1151
- }
1152
- async function runDeviceWorker(input) {
1153
- const runProcess = input.runProcess ?? runDeviceProcess;
1154
- const sleep = input.sleep ?? sleepWithAbort;
1155
- const random = input.random ?? Math.random;
1156
- let retryAttempt = 0;
1157
- while (!input.signal.aborted) {
1158
- let command;
1159
- try {
1160
- command = await input.client.lease(input.signal);
1161
- retryAttempt = 0;
1162
- } catch (error) {
1163
- if (input.signal.aborted) return;
1164
- if (error instanceof DeviceCredentialRejectedError) throw error;
1165
- if (!isRetryableIdleError(error)) throw error;
1166
- try {
1167
- await sleep(computeRetryDelay$1(retryAttempt, random), input.signal);
1168
- } catch (sleepError) {
1169
- if (input.signal.aborted && isAbortError(sleepError)) return;
1170
- throw sleepError;
1171
- }
1172
- retryAttempt += 1;
1173
- continue;
1174
- }
1175
- if (!command) continue;
1176
- await executeLease(command, input, runProcess, sleep);
1177
- }
1178
- }
1179
- //#endregion
1180
- //#region src/commands/device.ts
1181
- const NAMED_PROFILE_REQUIRED = "Device pairing requires a named Standards profile";
1182
- const SUPPORTED_PLATFORMS_REQUIRED = "Device workers support macOS and Linux only";
1183
- var DeviceCommandError = class extends SchemaError {
1184
- constructor(message) {
1185
- super(message, SchemaErrorCode.VALIDATION_FAILED);
1186
- this.name = "DeviceCommandError";
1187
- }
1188
- };
1189
- function requireSupportedPlatform(platform) {
1190
- if (platform === "darwin" || platform === "linux") return platform;
1191
- throw new DeviceCommandError(SUPPORTED_PLATFORMS_REQUIRED);
1192
- }
1193
- function requireNamedProfile(command) {
1194
- const options = getGlobalOptions(command);
1195
- if (!(options.profileName && options.apiUrl && options.apiKey)) throw new DeviceCommandError(NAMED_PROFILE_REQUIRED);
1196
- return {
1197
- apiUrl: options.apiUrl,
1198
- apiKey: options.apiKey,
1199
- profileName: options.profileName,
1200
- tenantId: options.tenant
1201
- };
1202
- }
1203
- function isRecord(value) {
1204
- return value !== null && typeof value === "object" && !Array.isArray(value);
1205
- }
1206
- function parseDeviceView(value) {
1207
- if (!isRecord(value)) throw new DeviceCommandError("Invalid device response");
1208
- const { id, name, platform, arch, hostname: deviceHostname } = value;
1209
- const { status } = value;
1210
- if (typeof id !== "string" || typeof name !== "string" || platform !== "darwin" && platform !== "linux" || typeof arch !== "string" || typeof deviceHostname !== "string" || status !== "online" && status !== "offline" && status !== "revoked") throw new DeviceCommandError("Invalid device response");
1211
- return {
1212
- id,
1213
- name,
1214
- platform,
1215
- arch,
1216
- hostname: deviceHostname,
1217
- status
1218
- };
1219
- }
1220
- function parsePairDeviceResponse(value) {
1221
- if (!isRecord(value) || typeof value.token !== "string") throw new DeviceCommandError("Invalid device pairing response");
1222
- return {
1223
- device: parseDeviceView(value.device),
1224
- token: value.token
1225
- };
1226
- }
1227
- function parseDeviceList(value) {
1228
- if (!Array.isArray(value)) throw new DeviceCommandError("Invalid device list response");
1229
- return value.map(parseDeviceView);
1230
- }
1231
- async function pairDevice(name, command) {
1232
- const platform = requireSupportedPlatform(process.platform);
1233
- const profile = requireNamedProfile(command);
1234
- const installationId = await getOrCreateInstallationId();
1235
- const client = createClient({
1236
- apiUrl: profile.apiUrl,
1237
- apiKey: profile.apiKey,
1238
- tenantId: profile.tenantId
1239
- });
1240
- const response = parsePairDeviceResponse(await createAppClient(client).devices.pair({
1241
- installationId,
1242
- name,
1243
- platform,
1244
- arch: process.arch,
1245
- hostname: hostname()
1246
- }));
1247
- if (!await storeDeviceCredential(profile.profileName, {
1248
- id: response.device.id,
1249
- name: response.device.name,
1250
- token: response.token
1251
- })) throw new DeviceCommandError(NAMED_PROFILE_REQUIRED);
1252
- process.stdout.write(`Paired ${response.device.name} (${response.device.id})\n`);
1253
- }
1254
- async function showDeviceStatus(command) {
1255
- const profile = requireNamedProfile(command);
1256
- const credential = await getStoredDeviceCredential(profile.profileName);
1257
- if (!credential) throw new DeviceCommandError("No paired device; run standards device pair first");
1258
- const client = createClient({
1259
- apiUrl: profile.apiUrl,
1260
- apiKey: profile.apiKey,
1261
- tenantId: profile.tenantId
1262
- });
1263
- const pairedDevice = parseDeviceList(await createAppClient(client).devices.list({})).find((device) => device.id === credential.id);
1264
- formatOutput(pairedDevice ? [pairedDevice] : [{
1265
- id: credential.id,
1266
- name: credential.name,
1267
- status: "revoked"
1268
- }], getFormat(command));
1269
- }
1270
- async function revokeDevice(command) {
1271
- const profile = requireNamedProfile(command);
1272
- const credential = await getStoredDeviceCredential(profile.profileName);
1273
- if (!credential) throw new DeviceCommandError("No paired device; run standards device pair first");
1274
- const client = createClient({
1275
- apiUrl: profile.apiUrl,
1276
- apiKey: profile.apiKey,
1277
- tenantId: profile.tenantId
1278
- });
1279
- await createAppClient(client).devices.revoke({ id: credential.id });
1280
- await removeDeviceCredential(profile.profileName);
1281
- }
1282
- async function serveDevice(command) {
1283
- const platform = requireSupportedPlatform(process.platform);
1284
- const profile = requireNamedProfile(command);
1285
- const credential = await getStoredDeviceCredential(profile.profileName);
1286
- if (!credential) throw new DeviceCommandError("No paired device; run standards device pair first");
1287
- const userClient = createClient({
1288
- apiUrl: profile.apiUrl,
1289
- apiKey: profile.apiKey,
1290
- tenantId: profile.tenantId
1291
- });
1292
- const device = parseDeviceList(await createAppClient(userClient).devices.list({})).find((candidate) => candidate.id === credential.id);
1293
- if (!device) throw new DeviceCommandError("Device is unavailable");
1294
- if (device.platform !== platform) throw new DeviceCommandError("Device platform does not match paired device");
1295
- const controller = new AbortController();
1296
- const stop = () => controller.abort();
1297
- process.once("SIGINT", stop);
1298
- process.once("SIGTERM", stop);
1299
- process.stdout.write(`Connected as ${credential.name}\n`);
1300
- process.stdout.write("Waiting for commands — press Ctrl+C to disconnect\n");
1301
- const client = new DeviceWorkerClient({
1302
- apiUrl: profile.apiUrl,
1303
- token: credential.token
1304
- });
1305
- try {
1306
- await runDeviceWorker({
1307
- client,
1308
- signal: controller.signal,
1309
- onEvent: (event) => {
1310
- if (event.type === "running") {
1311
- process.stdout.write(`Running ${event.commandId}: ${event.intent}\n`);
1312
- return;
1313
- }
1314
- process.stdout.write(`Completed ${event.commandId} with exit code ${event.exitCode}\n`);
1315
- }
1316
- });
1317
- } finally {
1318
- client.close();
1319
- process.removeListener("SIGINT", stop);
1320
- process.removeListener("SIGTERM", stop);
1321
- }
1322
- }
1323
- function registerDeviceCommand(program) {
1324
- const device = program.command("device").description("Pair and run this computer as a device");
1325
- device.command("pair").description("Pair this computer with Standards").requiredOption("--name <name>", "device name").action(async (options, command) => pairDevice(options.name, command));
1326
- device.command("status").description("Show the paired device status").action(async (_options, command) => showDeviceStatus(command));
1327
- device.command("revoke").description("Revoke this computer's device credential").action(async (_options, command) => revokeDevice(command));
1328
- device.command("serve").description("Run the foreground device worker").action(async (_options, command) => serveDevice(command));
1329
- }
1330
- //#endregion
1331
367
  //#region src/drift/shared-state.ts
1332
368
  async function readSchemaState(client) {
1333
369
  const state = parseSchemaState(await client.schemaState.get({}));
@@ -1767,6 +803,11 @@ function buildQueryBody(opts) {
1767
803
  if (opts.count) body.countMode = parseCountFlag(opts.count);
1768
804
  return body;
1769
805
  }
806
+ /** The new parent of a move: exactly one of `--parent <id>` or `--top-level` (null). */
807
+ function parseMoveTarget(opts) {
808
+ if (Boolean(opts.parent) === Boolean(opts.topLevel)) throw new ValidationError("Pass exactly one of --parent <id> or --top-level", []);
809
+ return opts.parent ?? null;
810
+ }
1770
811
  const COUNT_OPTION_DESCRIPTION = "total count mode (none, estimated, exact); omit for no total in page.total";
1771
812
  function registerRecordsCommand(program) {
1772
813
  const records = program.command("records").description("Manage records (CRUD + search)");
@@ -1783,11 +824,12 @@ function registerRecordsCommand(program) {
1783
824
  id
1784
825
  }), getFormat(cmd));
1785
826
  });
1786
- records.command("create").description("Create a new record").argument("<object>", "object name").option("--data <json>", "record data as JSON string").action(async (objectName, opts, cmd) => {
827
+ records.command("create").description("Create a new record").argument("<object>", "object name").option("--data <json>", "record data as JSON string").option("--parent <id>", "parent record ID (hierarchical objects only)").action(async (objectName, opts, cmd) => {
1787
828
  const data = opts.data ? JSON.parse(opts.data) : {};
1788
829
  formatOutput(await getAppClientFromCommand(cmd).records.create({
1789
830
  objectName,
1790
- data
831
+ data,
832
+ ...opts.parent ? { options: { parentId: opts.parent } } : {}
1791
833
  }), getFormat(cmd));
1792
834
  });
1793
835
  records.command("update").description("Update a record").argument("<object>", "object name").argument("<id>", "record ID").option("--data <json>", "fields to update as JSON string").action(async (objectName, id, opts, cmd) => {
@@ -1805,6 +847,26 @@ function registerRecordsCommand(program) {
1805
847
  });
1806
848
  process.stdout.write(`Record ${id} deleted.\n`);
1807
849
  });
850
+ records.command("deletion-count").description("Count what deleting records would take: broken links and descendants").argument("<object>", "object name").argument("<ids...>", "record IDs").action(async (objectName, ids, _opts, cmd) => {
851
+ formatOutput(await getAppClientFromCommand(cmd).records.deletionCount({
852
+ objectName,
853
+ ids
854
+ }), getFormat(cmd));
855
+ });
856
+ records.command("move-impact").description("Show who would gain and lose access if a record moved").argument("<object>", "object name").argument("<id>", "record ID").option("--parent <id>", "new parent record ID").option("--top-level", "move to the top level").action(async (objectName, id, opts, cmd) => {
857
+ formatOutput(await getAppClientFromCommand(cmd).records.moveImpact({
858
+ objectName,
859
+ recordId: id,
860
+ parentId: parseMoveTarget(opts)
861
+ }), getFormat(cmd));
862
+ });
863
+ records.command("move").description("Move a record and its descendants under a parent (preview with move-impact)").argument("<object>", "object name").argument("<id>", "record ID").option("--parent <id>", "new parent record ID").option("--top-level", "move to the top level").action(async (objectName, id, opts, cmd) => {
864
+ formatOutput(await getAppClientFromCommand(cmd).records.move({
865
+ objectName,
866
+ recordId: id,
867
+ parentId: parseMoveTarget(opts)
868
+ }), getFormat(cmd));
869
+ });
1808
870
  records.command("search").description("Full-text search records").argument("<object>", "object name").requiredOption("--query <q>", "search query string").option("--limit <n>", "max records to return").option("--offset <n>", "number of records to skip").option("--sort <sort>", "sort rule as \"attribute:direction\"").option("--filter <json>", "filter state as JSON string").option("--fields <names>", "comma-separated reference attributes to hydrate (empty for none, omit for all)").option("--count <mode>", COUNT_OPTION_DESCRIPTION).action(async (objectName, opts, cmd) => {
1809
871
  const body = {
1810
872
  q: opts.query,
@@ -1888,6 +950,124 @@ function registerRecordsCommand(program) {
1888
950
  });
1889
951
  }
1890
952
  //#endregion
953
+ //#region src/config.ts
954
+ const DEFAULT_API_URL = "http://localhost:4100/v1";
955
+ const ENV_PROFILE_NAME = "sandbox";
956
+ function getDefaultApiUrl() {
957
+ return DEFAULT_API_URL;
958
+ }
959
+ function getConfigPath() {
960
+ const configDir = process.env.STANDARDS_CONFIG_DIR ?? join(homedir(), ".standards");
961
+ return join(configDir, "config.json");
962
+ }
963
+ async function readConfig() {
964
+ try {
965
+ const raw = await readFile(getConfigPath(), "utf8");
966
+ const parsed = JSON.parse(raw);
967
+ return {
968
+ currentProfile: parsed.currentProfile,
969
+ profiles: parsed.profiles ?? {}
970
+ };
971
+ } catch (error) {
972
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return { profiles: {} };
973
+ throw error;
974
+ }
975
+ }
976
+ async function writeConfig(config) {
977
+ const path = getConfigPath();
978
+ const directory = dirname(path);
979
+ await mkdir(directory, {
980
+ recursive: true,
981
+ mode: 448
982
+ });
983
+ await chmod(directory, 448);
984
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
985
+ await chmod(path, 384);
986
+ }
987
+ async function upsertProfile(input) {
988
+ const config = await readConfig();
989
+ config.profiles[input.name] = {
990
+ apiUrl: input.apiUrl,
991
+ apiKey: input.apiKey
992
+ };
993
+ config.currentProfile = input.name;
994
+ await writeConfig(config);
995
+ }
996
+ async function setCurrentProfile(name) {
997
+ const config = await readConfig();
998
+ if (!config.profiles[name]) throw new Error(`Unknown Standards instance "${name}"`);
999
+ config.currentProfile = name;
1000
+ await writeConfig(config);
1001
+ }
1002
+ async function removeProfile(name) {
1003
+ const config = await readConfig();
1004
+ const profileName = name ?? config.currentProfile;
1005
+ if (!profileName) return;
1006
+ delete config.profiles[profileName];
1007
+ if (config.currentProfile === profileName) config.currentProfile = void 0;
1008
+ await writeConfig(config);
1009
+ }
1010
+ async function listProfiles() {
1011
+ const config = await readConfig();
1012
+ const profiles = Object.entries(config.profiles).sort(([a], [b]) => a.localeCompare(b)).map(([name, profile]) => ({
1013
+ name,
1014
+ apiUrl: profile.apiUrl,
1015
+ current: config.currentProfile === name
1016
+ }));
1017
+ const envProfile = getEnvProfile();
1018
+ if (envProfile) profiles.push({
1019
+ name: ENV_PROFILE_NAME,
1020
+ apiUrl: envProfile.apiUrl,
1021
+ current: !config.currentProfile,
1022
+ source: "env"
1023
+ });
1024
+ return profiles;
1025
+ }
1026
+ async function getActiveProfile() {
1027
+ const config = await readConfig();
1028
+ if (!config.currentProfile) {
1029
+ const envProfile = getEnvProfile();
1030
+ return envProfile ? {
1031
+ name: ENV_PROFILE_NAME,
1032
+ ...envProfile
1033
+ } : null;
1034
+ }
1035
+ const profile = config.profiles[config.currentProfile];
1036
+ if (!profile) return null;
1037
+ return {
1038
+ name: config.currentProfile,
1039
+ ...profile
1040
+ };
1041
+ }
1042
+ function getEnvProfile() {
1043
+ if (!process.env.STANDARDS_API_KEY) return null;
1044
+ return {
1045
+ apiUrl: process.env.STANDARDS_API_URL ?? DEFAULT_API_URL,
1046
+ apiKey: process.env.STANDARDS_API_KEY
1047
+ };
1048
+ }
1049
+ async function resolveCliConfig(options) {
1050
+ if (options.apiUrl || options.apiKey) return {
1051
+ apiUrl: options.apiUrl ?? process.env.STANDARDS_API_URL ?? DEFAULT_API_URL,
1052
+ apiKey: options.apiKey ?? process.env.STANDARDS_API_KEY
1053
+ };
1054
+ const config = await readConfig();
1055
+ const profileName = options.instance ?? config.currentProfile;
1056
+ if (profileName) {
1057
+ const profile = config.profiles[profileName];
1058
+ if (!profile) throw new Error(`Unknown Standards instance "${profileName}"`);
1059
+ return {
1060
+ apiUrl: profile.apiUrl,
1061
+ apiKey: profile.apiKey,
1062
+ profileName
1063
+ };
1064
+ }
1065
+ return {
1066
+ apiUrl: process.env.STANDARDS_API_URL ?? DEFAULT_API_URL,
1067
+ apiKey: process.env.STANDARDS_API_KEY
1068
+ };
1069
+ }
1070
+ //#endregion
1891
1071
  //#region src/commands/root.ts
1892
1072
  async function promptForMissingValue(label) {
1893
1073
  const rl = createInterface({
@@ -1977,6 +1157,153 @@ function registerSearchCommand(program) {
1977
1157
  });
1978
1158
  }
1979
1159
  //#endregion
1160
+ //#region src/commands/skills.ts
1161
+ const SKILL_FILE = "SKILL.md";
1162
+ /** A folder name: one path segment under the rule (`isSafeSkillFilePath`). */
1163
+ function isSafeSegment(segment) {
1164
+ return !segment.includes("/") && isSafeSkillFilePath(segment);
1165
+ }
1166
+ /**
1167
+ * A path the server answered, before it lands on this disk: the skill path
1168
+ * rule, and no drive letter (`C:`), which Windows would read as absolute.
1169
+ */
1170
+ function isSafeRelativePath(path) {
1171
+ return !/^[A-Za-z]:/.test(path) && isSafeSkillFilePath(path);
1172
+ }
1173
+ /**
1174
+ * Hidden entries (`.git`, `.env`, `.DS_Store`…) and symbolic links are not
1175
+ * sent: a link could point anywhere on the machine. Each one is reported.
1176
+ */
1177
+ function isSkipped(entry) {
1178
+ return entry.name.startsWith(".") || entry.isSymbolicLink();
1179
+ }
1180
+ async function isDirectory(path) {
1181
+ return (await stat(path)).isDirectory();
1182
+ }
1183
+ async function listFiles(dir, skipped) {
1184
+ const files = [];
1185
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
1186
+ const path = join(dir, entry.name);
1187
+ if (isSkipped(entry)) skipped.push(path);
1188
+ else if (entry.isDirectory()) files.push(...await listFiles(path, skipped));
1189
+ else if (entry.isFile()) files.push(path);
1190
+ }
1191
+ return files.sort();
1192
+ }
1193
+ /** A directory holding `SKILL.md` is one folder; any other is a directory of folders. */
1194
+ async function foldersIn(dir, skipped) {
1195
+ const entries = await readdir(dir, { withFileTypes: true });
1196
+ if (entries.some((entry) => entry.isFile() && entry.name === SKILL_FILE)) return [dir];
1197
+ const folders = [];
1198
+ for (const entry of entries) if (isSkipped(entry)) skipped.push(join(dir, entry.name));
1199
+ else if (entry.isDirectory()) folders.push(join(dir, entry.name));
1200
+ return folders.sort();
1201
+ }
1202
+ /** One folder as read: text as `content`, any other bytes uploaded and sent by id. */
1203
+ async function readFolder(client, dir, skipped) {
1204
+ const files = [];
1205
+ for (const absolute of await listFiles(dir, skipped)) {
1206
+ const path = relative(dir, absolute).split(sep).join("/");
1207
+ const bytes = await readFile(absolute);
1208
+ const content = skillFileText(bytes);
1209
+ if (content !== void 0) {
1210
+ files.push({
1211
+ path,
1212
+ content
1213
+ });
1214
+ continue;
1215
+ }
1216
+ const [stored] = await client.files.upload({ files: [new File([bytes], basename(absolute))] });
1217
+ if (!stored) throw new Error(`The upload of ${absolute} answered no file`);
1218
+ files.push({
1219
+ path,
1220
+ fileId: stored.id
1221
+ });
1222
+ }
1223
+ return {
1224
+ name: basename(resolve(dir)),
1225
+ files
1226
+ };
1227
+ }
1228
+ /**
1229
+ * `standards skills push`: every folder sent as read in one `skills.import`.
1230
+ * The CLI judges nothing: the server answers every problem, printed whole.
1231
+ */
1232
+ async function runSkillsPush(client, dirs, options = {}) {
1233
+ try {
1234
+ const folders = [];
1235
+ const skipped = [];
1236
+ for (const dir of dirs) {
1237
+ if (!await isDirectory(dir)) throw new Error(`${dir} is not a directory`);
1238
+ for (const folder of await foldersIn(dir, skipped)) folders.push(await readFolder(client, folder, skipped));
1239
+ }
1240
+ return {
1241
+ exitCode: 0,
1242
+ result: await client.skills.import({
1243
+ folders,
1244
+ onConflict: options.replace === true ? "replace" : "fail"
1245
+ }),
1246
+ skipped
1247
+ };
1248
+ } catch (error) {
1249
+ return {
1250
+ exitCode: 1,
1251
+ errorMessage: messageOf(error)
1252
+ };
1253
+ }
1254
+ }
1255
+ /** The bytes behind an exported file: its text, or its signed URL fetched. */
1256
+ async function exportedBytes(file) {
1257
+ if ("content" in file) return new TextEncoder().encode(file.content);
1258
+ const response = await fetch(file.url);
1259
+ if (!response.ok) throw new Error(`Downloading ${file.url} answered ${response.status}`);
1260
+ return new Uint8Array(await response.arrayBuffer());
1261
+ }
1262
+ /** `standards skills pull`: each skill written as `<dir>/<name>/`, its files at their paths. */
1263
+ async function runSkillsPull(client, dir, names) {
1264
+ const { folders } = await client.skills.export(names && names.length > 0 ? { names: [...names] } : {});
1265
+ const refused = folders.flatMap((folder) => isSafeSegment(folder.name) ? folder.files.filter((file) => !isSafeRelativePath(file.path)).map((file) => `skill ${JSON.stringify(folder.name)}: file path ${JSON.stringify(file.path)}`) : [`skill name ${JSON.stringify(folder.name)}`]);
1266
+ if (refused.length > 0) throw new Error(`Pull refused, nothing was written: each name must be one folder name and each path relative, inside it:\n${refused.map((line) => `- ${line}`).join("\n")}`);
1267
+ const target = resolve(dir);
1268
+ const written = [];
1269
+ for (const folder of folders) {
1270
+ const root = resolve(target, folder.name);
1271
+ for (const file of folder.files) {
1272
+ const destination = resolve(root, file.path);
1273
+ if (!(dirname(root) === target && destination.startsWith(`${root}${sep}`))) throw new Error(`Skill ${folder.name}: file path ${file.path} leaves ${target}`);
1274
+ await mkdir(dirname(destination), { recursive: true });
1275
+ await writeFile(destination, await exportedBytes(file));
1276
+ }
1277
+ written.push(root);
1278
+ }
1279
+ return written;
1280
+ }
1281
+ function collectName(value, names) {
1282
+ return [...names, value];
1283
+ }
1284
+ /** Skills as Agent Skills folders: pull them to disk, push folders back. */
1285
+ function registerSkillsCommand(program) {
1286
+ const skills = program.command("skills").description("Pull and push skills as Agent Skills folders (SKILL.md and its files)");
1287
+ skills.command("pull").description("Write each skill as a folder <dir>/<name>/ (every skill you can open by default)").argument("[dir]", "directory to write the folders into", ".").option("--name <name>", "a skill to pull (repeat for several)", collectName, []).action(async (dir, opts, cmd) => {
1288
+ try {
1289
+ formatOutput({ written: await runSkillsPull(getAppClientFromCommand(cmd), dir, opts.name) }, getFormat(cmd));
1290
+ } catch (error) {
1291
+ console.error(styleText("red", `✗ ${messageOf(error)}`));
1292
+ process.exit(1);
1293
+ }
1294
+ });
1295
+ skills.command("push").description("Import skill folders: a folder holding SKILL.md, or a directory of such folders. All or nothing").argument("<dirs...>", "skill folders, or directories of skill folders").option("--replace", "rewrite the skill a name already reads instead of refusing it").action(async (dirs, opts, cmd) => {
1296
+ const outcome = await runSkillsPush(getAppClientFromCommand(cmd), dirs, opts);
1297
+ if (outcome.exitCode === 1) {
1298
+ console.error(styleText("red", `✗ ${outcome.errorMessage}`));
1299
+ process.exit(1);
1300
+ }
1301
+ for (const path of outcome.skipped) console.warn(styleText("yellow", `! skipped ${path} (hidden or a symbolic link)`));
1302
+ for (const warning of outcome.result.warnings) console.warn(styleText("yellow", `! ${warning}`));
1303
+ formatOutput(outcome.result, getFormat(cmd));
1304
+ });
1305
+ }
1306
+ //#endregion
1980
1307
  //#region src/version.ts
1981
1308
  /**
1982
1309
  * The installed package's version, read from its own manifest: `src/` and the
@@ -2015,7 +1342,6 @@ function createProgram() {
2015
1342
  registerPullCommand(program);
2016
1343
  registerDiffCommand(program);
2017
1344
  registerDocumentsCommand(program);
2018
- registerDeviceCommand(program);
2019
1345
  registerFoldersCommand(program);
2020
1346
  registerKeysCommand(program);
2021
1347
  registerInvitationsCommand(program);
@@ -2026,6 +1352,7 @@ function createProgram() {
2026
1352
  registerMeetingsCommand(program);
2027
1353
  registerMemoryCommand(program);
2028
1354
  registerSearchCommand(program);
1355
+ registerSkillsCommand(program);
2029
1356
  registerEmbeddingsCommand(program);
2030
1357
  registerAiCommand(program);
2031
1358
  registerQuotasCommand(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@standards/cli",
3
- "version": "1.0.0-alpha.408",
3
+ "version": "1.0.0-alpha.410",
4
4
  "description": "CLI tool to interact with Standards API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,14 +26,12 @@
26
26
  "cli-table3": "^0.6.5",
27
27
  "commander": "^13.1.0",
28
28
  "jiti": "2.7.0",
29
- "ws": "8.21.1",
30
- "@standards/client": "1.0.0-alpha.408",
31
- "@standards/constants": "1.0.0-alpha.408",
32
- "@standards/schema": "1.0.0-alpha.408"
29
+ "@standards/client": "1.0.0-alpha.410",
30
+ "@standards/constants": "1.0.0-alpha.410",
31
+ "@standards/schema": "1.0.0-alpha.410"
33
32
  },
34
33
  "devDependencies": {
35
34
  "@types/node": "^25.6.0",
36
- "@types/ws": "8.18.1",
37
35
  "jsdom": "^25.0.1",
38
36
  "tsdown": "^0.23.0",
39
37
  "typescript": "^5.6.3",