@aryacode/dsh-aryacode-polish 0.3.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -19,11 +19,23 @@ window.__ModuleLoader__.load({
19
19
  "label": "Account balance",
20
20
  "description": "Show the remaining account balance in the sidebar foot, above the account row.",
21
21
  "defaultOn": true
22
+ },
23
+ {
24
+ "key": "openWorkspaceFolder",
25
+ "label": "Open workspace folder",
26
+ "description": "Add an “Open workspace folder” row to a Session’s menu that opens its workspace in the file browser.",
27
+ "defaultOn": true
28
+ },
29
+ {
30
+ "key": "githubWorkspace",
31
+ "label": "Add a workspace from GitHub",
32
+ "description": "Sign in with the GitHub CLI, browse your repositories, and clone one as a workspace.",
33
+ "defaultOn": true
22
34
  }
23
35
  ]
24
36
  const ROW_ID = "polish"
25
37
  const ROW_KEY = "@aryacode/dsh-aryacode-polish" + '#' + ROW_ID
26
- const CLIENT_VERSION = "0.3.9"
38
+ const CLIENT_VERSION = "0.5.0"
27
39
 
28
40
  /**
29
41
  * The two pure decisions behind the balance line: which wallet to show, and how
@@ -166,6 +178,19 @@ window.__ModuleLoader__.load({
166
178
  const React = require('react')
167
179
  const ReactDOM = require('react-dom')
168
180
 
181
+ /**
182
+ * The Shell's own UI primitives. Guarded because the menu row degrades to a plain
183
+ * button when a Shell answers no primitives, and a bundle that throws on load
184
+ * would take every other surface down with it.
185
+ */
186
+ const primitives = (() => {
187
+ try {
188
+ return require('@deepseek-ai/dsh-client-ui-primitives')
189
+ } catch {
190
+ return undefined
191
+ }
192
+ })()
193
+
169
194
  /** Shared spacing for the page's prose. */
170
195
  const SPACING = { margin: '0 0 10px', opacity: 0.85, lineHeight: '1.5' }
171
196
 
@@ -666,22 +691,391 @@ window.__ModuleLoader__.load({
666
691
  return React.createElement('div', { 'data-polish-balance': 'empty', style: { display: 'none' } })
667
692
  }
668
693
  const money = formatBalance(wallet)
694
+ // The foot is a single row shared with the GitHub entry, so the amount stays
695
+ // bare: a tooltip names it, and nothing here competes for width.
669
696
  return React.createElement('div', {
670
697
  title: 'Remaining account balance',
698
+ 'data-polish-balance': 'ready',
671
699
  style: {
672
700
  display: 'flex',
673
701
  alignItems: 'center',
674
702
  gap: '6px',
675
- padding: '2px 8px 4px',
703
+ padding: '2px 4px 4px 8px',
676
704
  fontSize: '12px',
677
705
  lineHeight: '16px',
678
- minWidth: 0,
679
- overflow: 'hidden',
706
+ flex: '0 0 auto',
680
707
  whiteSpace: 'nowrap',
681
- textOverflow: 'ellipsis',
682
708
  opacity: 0.75,
709
+ fontVariantNumeric: 'tabular-nums',
710
+ },
711
+ }, money)
712
+ }
713
+
714
+ /**
715
+ * The workspace a Session belongs to.
716
+ *
717
+ * Occupants of `sidebar.workspaces.session.menu.item` address their data by
718
+ * Session id and must not bind or retain the Session, so the workspace is read
719
+ * from the Workspace snapshot at render time and never memoized.
720
+ *
721
+ * @param workspaces - the Shell's Workspace service, when it is loaded.
722
+ * @param sessionId - the row's Session.
723
+ * @returns the owning workspace, or undefined when it cannot be resolved.
724
+ */
725
+ function workspaceOf(workspaces, sessionId) {
726
+ if (workspaces === undefined || workspaces === null || sessionId === undefined) return undefined
727
+ let items
728
+ try {
729
+ items = workspaces.list.getSnapshot().items
730
+ } catch {
731
+ return undefined
732
+ }
733
+ if (!Array.isArray(items)) return undefined
734
+ return items.find(item => Array.isArray(item.sessionIds)
735
+ && item.sessionIds.indexOf(sessionId) !== -1
736
+ && typeof item.path === 'string'
737
+ && item.path !== '')
738
+ }
739
+
740
+ /**
741
+ * "Open workspace folder": one row in a Session's "…" menu.
742
+ *
743
+ * It renders nothing when the switch is off or the Session has no workspace the
744
+ * Host could open, because a menu row that cannot act is worse than no row.
745
+ *
746
+ * @param props - the row's Session id, our lazy `configForms`/`workspaces`, the
747
+ * opener, and the slot's injected `useMenuOpenState` hook.
748
+ * @returns the menu row, or null.
749
+ */
750
+ function OpenFolderMenuItem(props) {
751
+ const enabled = useSwitch(props.configForms, 'openWorkspaceFolder', true)
752
+ // Hooks run unconditionally; the row simply renders nothing when it cannot act.
753
+ const menu = typeof props.useMenuOpenState === 'function' ? props.useMenuOpenState() : undefined
754
+ const closeMenu = Array.isArray(menu) && typeof menu[1] === 'function' ? menu[1] : undefined
755
+ const workspace = enabled ? workspaceOf(props.workspaces, props.sessionId) : undefined
756
+ if (!enabled || workspace === undefined) return null
757
+ const label = 'Open workspace folder'
758
+ const onSelect = () => {
759
+ if (closeMenu !== undefined) closeMenu(false)
760
+ const open = props.openFolder
761
+ if (typeof open !== 'function') return
762
+ void Promise.resolve()
763
+ .then(() => open(workspace))
764
+ .catch(error => {
765
+ // A menu row that silently does nothing is unfixable in the field, so the
766
+ // failure is reported instead of dropped. This is the only diagnostic the
767
+ // bundle writes.
768
+ if (typeof console !== 'undefined' && typeof console.warn === 'function') {
769
+ console.warn('[aryacode-polish] open workspace folder failed:', error && error.message ? error.message : error)
770
+ }
771
+ })
772
+ }
773
+ if (primitives !== undefined && typeof primitives.MenuItemButton === 'function') {
774
+ const icon = typeof primitives.IconFolderOpenRegular === 'function'
775
+ ? React.createElement(primitives.IconFolderOpenRegular)
776
+ : undefined
777
+ return React.createElement(primitives.MenuItemButton, { icon, onSelect }, label)
778
+ }
779
+ return React.createElement('button', { type: 'button', role: 'menuitem', onClick: onSelect }, label)
780
+ }
781
+
782
+ /** The Host route the GitHub halves talk over. Document-relative, like every app route. */
783
+ const GITHUB_ROUTE = 'api/polish.github'
784
+
785
+ /**
786
+ * Whether the GitHub panel is open. One store for the whole bundle: the footer
787
+ * row opens it, and the overlay panel is the only reader.
788
+ */
789
+ const githubStore = (() => {
790
+ let open = false
791
+ const listeners = new Set()
792
+ return {
793
+ isOpen: () => open,
794
+ subscribe(listener) {
795
+ listeners.add(listener)
796
+ return () => { listeners.delete(listener) }
797
+ },
798
+ set(next) {
799
+ if (open === next) return
800
+ open = next
801
+ for (const listener of listeners) listener()
802
+ },
803
+ }
804
+ })()
805
+
806
+ /**
807
+ * Post one action to the GitHub route.
808
+ * @param action - `status`, `repos` or `clone`.
809
+ * @param extra - fields the action needs.
810
+ * @returns the route's `value`, or throws with its own message.
811
+ */
812
+ async function githubCall(action, extra) {
813
+ const response = await fetch(GITHUB_ROUTE, {
814
+ method: 'POST',
815
+ headers: { 'content-type': 'application/json' },
816
+ body: JSON.stringify({ action, ...(extra ?? {}) }),
817
+ })
818
+ const payload = await response.json().catch(() => undefined)
819
+ if (payload === undefined || typeof payload !== 'object') {
820
+ throw new Error(`the Host answered ${String(response.status)}`)
821
+ }
822
+ if (payload.ok !== true) throw new Error(payload.error || 'GitHub request failed')
823
+ return payload.value
824
+ }
825
+
826
+ /** Modal backdrop and panel skin, matching the Shell's own dialogs. */
827
+ const GITHUB_BACKDROP_STYLE = {
828
+ position: 'fixed',
829
+ inset: 0,
830
+ zIndex: 1200,
831
+ display: 'flex',
832
+ alignItems: 'center',
833
+ justifyContent: 'center',
834
+ background: 'rgba(16, 18, 22, 0.32)',
835
+ }
836
+
837
+ const GITHUB_CARD_STYLE = {
838
+ ...PANEL_STYLE,
839
+ position: 'static',
840
+ width: 'min(560px, calc(100vw - 32px))',
841
+ maxHeight: 'min(560px, calc(100vh - 64px))',
842
+ display: 'flex',
843
+ flexDirection: 'column',
844
+ overflow: 'hidden',
845
+ }
846
+
847
+ const GITHUB_LIST_STYLE = {
848
+ marginTop: '10px',
849
+ overflowY: 'auto',
850
+ borderTop: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
851
+ }
852
+
853
+ const GITHUB_ROW_STYLE = {
854
+ display: 'flex',
855
+ alignItems: 'center',
856
+ gap: '8px',
857
+ width: '100%',
858
+ padding: '8px 4px',
859
+ border: 'none',
860
+ background: 'transparent',
861
+ color: 'inherit',
862
+ font: 'inherit',
863
+ textAlign: 'left',
864
+ cursor: 'pointer',
865
+ borderBottom: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.08))',
866
+ }
867
+
868
+ const GITHUB_INPUT_STYLE = {
869
+ width: '100%',
870
+ boxSizing: 'border-box',
871
+ padding: '6px 8px',
872
+ font: 'inherit',
873
+ color: 'inherit',
874
+ background: 'transparent',
875
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.2))',
876
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
877
+ }
878
+
879
+ /**
880
+ * The add-a-workspace-from-GitHub dialog.
881
+ *
882
+ * Sign-in is the GitHub CLI's own session: the Host asks `gh` for the token it
883
+ * already holds, so this dialog never collects a credential. A clone becomes a
884
+ * workspace through the Workspace Remote, which is the same call the "+" menu
885
+ * makes after its folder picker returns a path.
886
+ *
887
+ * @param props - our lazy `configForms` and the `createWorkspace` opener.
888
+ * @returns the dialog, or null while it is closed or switched off.
889
+ */
890
+ function GitHubPanel(props) {
891
+ const open = React.useSyncExternalStore(githubStore.subscribe, githubStore.isOpen)
892
+ const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
893
+ const [phase, setPhase] = React.useState('idle')
894
+ const [account, setAccount] = React.useState(undefined)
895
+ const [repos, setRepos] = React.useState([])
896
+ const [query, setQuery] = React.useState('')
897
+ const [error, setError] = React.useState('')
898
+ const [busy, setBusy] = React.useState('')
899
+
900
+ React.useEffect(() => {
901
+ if (!open || !enabled) return undefined
902
+ let live = true
903
+ setPhase('loading')
904
+ setError('')
905
+ setBusy('')
906
+ setQuery('')
907
+ Promise.resolve()
908
+ .then(() => githubCall('status'))
909
+ .then(status => {
910
+ if (!live) return undefined
911
+ setAccount(status)
912
+ if (status.signedIn !== true) return undefined
913
+ return githubCall('repos').then(list => {
914
+ if (!live) return
915
+ setRepos(Array.isArray(list.repos) ? list.repos : [])
916
+ })
917
+ })
918
+ .then(() => { if (live) setPhase('ready') })
919
+ .catch(reason => {
920
+ if (!live) return
921
+ setError(reason instanceof Error ? reason.message : String(reason))
922
+ setPhase('ready')
923
+ })
924
+ return () => { live = false }
925
+ }, [open, enabled])
926
+
927
+ React.useEffect(() => {
928
+ if (!open) return undefined
929
+ const onKey = event => { if (event.key === 'Escape') githubStore.set(false) }
930
+ document.addEventListener('keydown', onKey)
931
+ return () => document.removeEventListener('keydown', onKey)
932
+ }, [open])
933
+
934
+ if (!open || !enabled) return null
935
+
936
+ const signedIn = account !== undefined && account.signedIn === true
937
+ const needle = query.trim().toLowerCase()
938
+ const visible = needle === ''
939
+ ? repos
940
+ : repos.filter(repo => `${repo.fullName} ${repo.description}`.toLowerCase().includes(needle))
941
+ const root = account !== undefined && typeof account.root === 'string' ? account.root : ''
942
+
943
+ const clone = repo => {
944
+ if (busy !== '') return
945
+ setBusy(repo.fullName)
946
+ setError('')
947
+ Promise.resolve()
948
+ .then(() => githubCall('clone', { owner: repo.owner, name: repo.name }))
949
+ .then(result => Promise.resolve(props.createWorkspace(result.path)).then(() => result))
950
+ .then(result => {
951
+ setBusy('')
952
+ githubStore.set(false)
953
+ if (typeof console !== 'undefined' && typeof console.info === 'function') {
954
+ console.info('[aryacode-polish] workspace ready:', result.path)
955
+ }
956
+ })
957
+ .catch(reason => {
958
+ setBusy('')
959
+ setError(reason instanceof Error ? reason.message : String(reason))
960
+ })
961
+ }
962
+
963
+ const rows = visible.slice(0, 200).map(repo => React.createElement('button', {
964
+ key: repo.fullName,
965
+ type: 'button',
966
+ style: GITHUB_ROW_STYLE,
967
+ disabled: busy !== '',
968
+ onClick: () => clone(repo),
969
+ title: repo.description === '' ? repo.fullName : `${repo.fullName} — ${repo.description}`,
970
+ 'data-polish-github-row': repo.fullName,
971
+ },
972
+ React.createElement('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
973
+ repo.fullName,
974
+ repo.isPrivate ? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, 'private') : null,
975
+ repo.isFork ? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, 'fork') : null),
976
+ repo.cloned === true
977
+ ? React.createElement('span', { style: { opacity: 0.6 } }, 'cloned')
978
+ : null,
979
+ busy === repo.fullName ? React.createElement('span', { style: { opacity: 0.7 } }, 'cloning…') : null))
980
+
981
+ return ReactDOM.createPortal(
982
+ React.createElement('div', {
983
+ style: GITHUB_BACKDROP_STYLE,
984
+ 'data-polish-github': 'dialog',
985
+ onPointerDown: event => { if (event.target === event.currentTarget) githubStore.set(false) },
683
986
  },
684
- }, props.wide === false ? money : 'Balance ' + money)
987
+ React.createElement('div', { style: GITHUB_CARD_STYLE, role: 'dialog', 'aria-label': 'Add a workspace from GitHub' },
988
+ React.createElement('div', { style: { ...PANEL_TITLE_STYLE, marginBottom: '4px' } },
989
+ React.createElement('span', null, 'Add a workspace from GitHub'),
990
+ React.createElement('button', {
991
+ type: 'button',
992
+ onClick: () => githubStore.set(false),
993
+ style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7 },
994
+ 'aria-label': 'Close',
995
+ }, '×')),
996
+ React.createElement('div', { style: { opacity: 0.75, minHeight: '18px' } },
997
+ phase === 'loading' && !signedIn ? 'Checking the GitHub CLI session…' : null,
998
+ signedIn ? `Signed in as ${String(account.login)} · cloning into ${root}` : null,
999
+ !signedIn && phase !== 'loading'
1000
+ ? 'The GitHub CLI is not signed in. Run `gh auth login` in a terminal, then reopen this dialog.'
1001
+ : null),
1002
+ signedIn
1003
+ ? React.createElement('input', {
1004
+ style: { ...GITHUB_INPUT_STYLE, marginTop: '10px' },
1005
+ value: query,
1006
+ placeholder: 'Search your repositories',
1007
+ 'aria-label': 'Search your repositories',
1008
+ autoFocus: true,
1009
+ onChange: event => setQuery(event.target.value),
1010
+ })
1011
+ : null,
1012
+ error !== ''
1013
+ ? React.createElement('div', { style: { marginTop: '10px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
1014
+ : null,
1015
+ signedIn
1016
+ ? React.createElement('div', { style: GITHUB_LIST_STYLE, 'data-polish-github-list': String(visible.length) },
1017
+ phase === 'loading' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'Loading repositories…') : null,
1018
+ phase === 'ready' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'No repository matches that search.') : null,
1019
+ ...rows)
1020
+ : null,
1021
+ React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } },
1022
+ 'The clone is added as a workspace. Sign-in comes from the GitHub CLI, so this plugin stores no token.')))
1023
+ , document.body)
1024
+ }
1025
+
1026
+ /**
1027
+ * The footer row that opens the GitHub dialog.
1028
+ * @param props - our lazy `configForms`.
1029
+ * @returns the row, or null when the feature is switched off.
1030
+ */
1031
+ function GithubEntry(props) {
1032
+ const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
1033
+ if (!enabled) return null
1034
+ return React.createElement('div', { style: { padding: '2px 8px 4px', minWidth: 0 } },
1035
+ React.createElement('button', {
1036
+ type: 'button',
1037
+ 'data-polish-github': 'entry',
1038
+ title: 'Add a workspace from GitHub',
1039
+ 'aria-label': 'Add a workspace from GitHub',
1040
+ onClick: () => githubStore.set(true),
1041
+ style: {
1042
+ display: 'flex',
1043
+ alignItems: 'center',
1044
+ gap: '6px',
1045
+ maxWidth: '100%',
1046
+ minWidth: 0,
1047
+ padding: '2px 6px',
1048
+ border: 'none',
1049
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1050
+ background: 'transparent',
1051
+ color: 'var(--dsw-alias-label-tertiary, inherit)',
1052
+ font: 'inherit',
1053
+ fontSize: '12px',
1054
+ lineHeight: '18px',
1055
+ fontVariantNumeric: 'tabular-nums',
1056
+ whiteSpace: 'nowrap',
1057
+ overflow: 'hidden',
1058
+ textOverflow: 'ellipsis',
1059
+ cursor: 'pointer',
1060
+ },
1061
+ onMouseEnter: event => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1062
+ onMouseLeave: event => { event.currentTarget.style.background = 'transparent' },
1063
+ },
1064
+ React.createElement('svg', {
1065
+ width: 14,
1066
+ height: 14,
1067
+ viewBox: '0 0 16 16',
1068
+ fill: 'none',
1069
+ stroke: 'currentColor',
1070
+ strokeWidth: 1.2,
1071
+ strokeLinecap: 'round',
1072
+ strokeLinejoin: 'round',
1073
+ 'aria-hidden': 'true',
1074
+ style: { flex: 'none' },
1075
+ },
1076
+ React.createElement('path', { d: 'M2.5 5.5h4l1.2 1.5h5.8v6.5h-11z' }),
1077
+ React.createElement('path', { d: 'M8 9.8v3.4M6.3 11.5h3.4' })),
1078
+ 'Add from GitHub'))
685
1079
  }
686
1080
 
687
1081
  return {
@@ -720,6 +1114,55 @@ window.__ModuleLoader__.load({
720
1114
  inject: () => ({ configForms, loadBalance }),
721
1115
  }, BalanceLine))
722
1116
  })
1117
+ // The Workspace service and the Session Remote are both required for the menu
1118
+ // row: it reads the folder from the first and opens it through the second.
1119
+ // They are declared on the inject list because a Remote namespace may not be
1120
+ // read through `ctx.get` — the Shell's guard refuses it.
1121
+ ctx.inject(['workspaces', 'remote', 'remote.session'], (workspaceCtx) => {
1122
+ const openFolder = async (workspace) => {
1123
+ const remote = workspaceCtx.remote.session
1124
+ // A Remote method answers an envelope (`{ ok, value }`), not the bare
1125
+ // return value; a Shell that answered a plain boolean is tolerated too.
1126
+ const answer = await remote.canOpenWorkspacePath()
1127
+ const available = answer !== null && typeof answer === 'object'
1128
+ ? answer.ok === true && answer.value === true
1129
+ : answer === true
1130
+ if (!available) throw new Error('this Host cannot open a folder')
1131
+ const result = await remote.openWorkspacePath({ path: workspace.path })
1132
+ if (result !== null && typeof result === 'object' && 'ok' in result && result.ok !== true) {
1133
+ throw new Error('the Host refused to open the folder')
1134
+ }
1135
+ }
1136
+ workspaceCtx.slots.inject('sidebar.workspaces.session.menu.item', () => workspaceCtx.slots.register({
1137
+ name: 'sidebar.workspaces.session.menu.item',
1138
+ id: 'polish-open-folder',
1139
+ order: 460,
1140
+ inject: () => ({ configForms, openFolder, workspaces: workspaceCtx.get('workspaces') }),
1141
+ }, OpenFolderMenuItem))
1142
+ })
1143
+ // Adding a workspace from GitHub goes through the Workspace service's own
1144
+ // create call — the same one the "+" menu makes once its folder picker
1145
+ // returns a path — so a clone lands in the sidebar exactly like a picked
1146
+ // folder does, with no Harness change anywhere. The service takes the plain
1147
+ // path and throws on refusal; the Remote namespace underneath it is
1148
+ // `ctx.remote.workspace` (singular), which is why this injects the service.
1149
+ ctx.inject(['workspaces'], (githubCtx) => {
1150
+ const createWorkspace = async (path) => {
1151
+ const workspace = await githubCtx.workspaces.create({ path })
1152
+ return workspace
1153
+ }
1154
+ githubCtx.slots.inject('shell.overlay', () => githubCtx.slots.register({
1155
+ name: 'shell.overlay',
1156
+ id: 'polish-github',
1157
+ inject: () => ({ configForms, createWorkspace }),
1158
+ }, GitHubPanel))
1159
+ githubCtx.slots.inject('sidebar.footer.action', () => githubCtx.slots.register({
1160
+ name: 'sidebar.footer.action',
1161
+ id: 'polish-github-entry',
1162
+ order: 4,
1163
+ inject: () => ({ configForms }),
1164
+ }, GithubEntry))
1165
+ })
723
1166
  },
724
1167
  }
725
1168
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-aryacode-polish",
3
- "version": "0.3.9",
3
+ "version": "0.5.0",
4
4
  "description": "AryaCode Polish: one install for the small things that make AryaCode feel finished. One Plugins row, one switch per quality-of-life feature.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -51,7 +51,7 @@
51
51
  "scripts": {
52
52
  "build:client": "node scripts/build-client.mjs",
53
53
  "prepack": "node scripts/build-client.mjs",
54
- "test": "node --test test/patch.spec.mjs test/config.spec.mjs test/registry.spec.mjs test/cost.spec.mjs test/money.spec.mjs test/account.spec.mjs test/docs.spec.mjs test/manifests.spec.mjs test/client-face.spec.mjs"
54
+ "test": "node --test test/patch.spec.mjs test/config.spec.mjs test/registry.spec.mjs test/cost.spec.mjs test/money.spec.mjs test/account.spec.mjs test/github.spec.mjs test/docs.spec.mjs test/manifests.spec.mjs test/client-face.spec.mjs"
55
55
  },
56
56
  "dependencies": {
57
57
  "zod": "^4.4.3"