@standchat/cli 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -4,15 +4,16 @@ Command-line access for installing and managing Stand Chat. The CLI covers the 1
4
4
 
5
5
  ## Installation and browser handoff
6
6
 
7
- After the release is publicly published:
7
+ Install the official package from npm’s `latest` tag after the release is publicly published. Record `stand --version` for troubleshooting; if the official package is unavailable, stop rather than substituting an unrelated package:
8
8
 
9
9
  ```sh
10
10
  npm install --global @standchat/cli
11
+ stand --version
11
12
  stand auth login --setup --json
12
13
  stand setup status --json
13
14
  ```
14
15
 
15
- `auth login` emits a `device_authorization` event on stderr with a trusted verification URL and a separate public user code. Keep the command running while the human opens the URL, signs up or signs in, accepts terms and approves access. Do not ask the human to paste access tokens, cookies or refresh tokens into the conversation. Browser approval never permits the agent to invent identity details or accept terms for the human.
16
+ `auth login` emits a `device_authorization` event on stderr with a trusted verification URL and a separate public user code. Keep the command running while the human opens the URL, signs up or signs in, accepts terms and approves access. Do not ask the human to paste access tokens, cookies or refresh tokens into the conversation. Browser approval never permits the agent to invent identity details or accept terms for the human. Successful device authorization ends on “Stand CLI authorized”; the human can close that page and return to the terminal. The CLI confirms credential storage separately; existing browser onboarding may still be required.
16
17
 
17
18
  `setup status` performs reads only. If it reports `browser_onboarding_required`, the human must open the indicated dashboard using the same account and complete the existing profile/organization onboarding form. Run `setup status` again afterward. A ready result means configuration may begin; it does not mean that a widget or chat has been verified. Disabled membership and unreadable account data fail closed. For a custom environment, provide its verified dashboard origin with `--app-url`; no dashboard address is guessed from an arbitrary API hostname.
18
19
 
@@ -49,7 +50,7 @@ stand standins set-enabled STANDIN_ID --enabled true --yes --json
49
50
  stand standins status --json
50
51
  ```
51
52
 
52
- Use only approved public website content, or approved project content within the existing instruction limits. Free Base-plan installation must work without paid uploads or sitemap features. Do not crawl localhost or private addresses. Check task completion and ingested documents before claiming that knowledge is ready. Attachment writes replace the complete list: read existing attachments and preserve those the user has not authorized removing.
53
+ Use only approved public website content, or approved project content within the existing instruction limits. On plans whose effective `capabilities.documentUploadAllowed` is true, upload an approved local document directly with `stand knowledge upload KNOWLEDGE_BASE_ID --file /absolute/path/approved-document.md --yes --json`; there is no need to publish it as a web page to crawl it. Explicitly approve sharing any private content with Stand and exposing its facts to visitors. Check ingestion availability, remaining capacity and `graceReadOnly` first. Free Base-plan installation must work without paid uploads or sitemap features. Do not crawl localhost or private addresses. Check task completion and ingested documents before claiming that knowledge is ready. Attachment writes replace the complete list: read existing attachments and preserve those the user has not authorized removing.
53
54
 
54
55
  The account-wide switch is separate from each Stand-in's enablement. `stand standins master --enabled true --yes --json` affects existing enabled Stand-ins as well. Inspect them and obtain approval for that shared effect on an existing account. Site and existing Stand-in configuration updates are supported through `stand sites update` and `stand standins update`. Read the current resource and its request schema first: some settings and collections are complete replacements, and concurrent writes have no conditional revision protection.
55
56
 
@@ -70,16 +71,35 @@ stand standins update STANDIN_ID --body-file standin.json --yes --json
70
71
 
71
72
  Account deletion, subscription/payment management, team-member mutations, initial account bootstrap and JavaScript experimentation opt-in are excluded. An owner must enable experimentation in the dashboard before an authorized client can edit scripts. API calls cannot enable either `advancedBehaviorEnabled` or `allowStandAdminBehavior`; a request that tries to enable them fails before other Site changes are saved. Read the current Site and preserve unrelated behavior. Supplying legacy `embedOptions` replaces behavior rules and resets experimentation consent.
72
73
 
73
- Multipart operations accept their documented form fields. Uploads retain existing plan and size limits:
74
+ Knowledge management has named commands over the same published API operations. Read `stand knowledge list --json` for effective plan capabilities and current ingestion constraints. Document uploads require `documentUploadAllowed` (normally Pro or Business); sitemap creation requires `sitemapAllowed`, and enabling nightly refresh requires `automaticRefreshAllowed` (Business). Disabling a preserved nightly setting remains possible after a downgrade. Backend ownership, worker availability, capacity and grace-state checks still apply.
75
+
76
+ ```sh
77
+ stand knowledge upload KNOWLEDGE_BASE_ID --file /absolute/path/approved-document.md --yes --json
78
+ stand knowledge upload KNOWLEDGE_BASE_ID --file /absolute/path/replacement.md --source SOURCE_ID --yes --json
79
+ stand knowledge sitemap KNOWLEDGE_BASE_ID --body-file sitemap.json --yes --json
80
+ stand knowledge sources refresh SOURCE_ID --enabled true --yes --json
81
+ stand knowledge sources recrawl SOURCE_ID --yes --json
82
+ stand knowledge documents get DOCUMENT_ID --json
83
+ stand knowledge documents chunks DOCUMENT_ID --json
84
+ stand knowledge documents search DOCUMENT_ID --query 'approved question' --top-k 5 --json
85
+ stand knowledge search KNOWLEDGE_BASE_ID --query 'approved question' --top-k 5 --json
86
+ stand knowledge tasks cancel TASK_ID --yes --json
87
+ stand knowledge update KNOWLEDGE_BASE_ID --body-file knowledge.json --yes --json
88
+ stand knowledge agents KNOWLEDGE_BASE_ID --json
89
+ ```
90
+
91
+ Uploads accept a nonempty `.pdf`, `.docx`, `.md`, `.markdown` or `.txt` file up to **10 MiB (10,485,760 bytes)**. `stand commands --json` exposes those limits, and `stand schema uploadKnowledgeBaseSource` describes the multipart request. `--source` replaces an existing uploaded source in the same knowledge base; it cannot replace a website or sitemap source. A source with an open indexing task must finish or be cancelled first. Existing completed content remains active during replacement; a queued task alone does not prove successful indexing. Inspect tasks and documents before using the new content. Sitemap input is `{"sitemapUrl":"https://example.com/sitemap.xml","dailyRefreshEnabled":false}`; request nightly refresh only when authorized and allowed.
92
+
93
+ `stand knowledge sources delete SOURCE_ID --yes` removes the source and its indexed documents; `stand knowledge documents delete DOCUMENT_ID --yes` removes the selected document. `stand knowledge delete KNOWLEDGE_BASE_ID --yes` first reads and displays connected Stand-ins. It requires both `knowledge:read` and `knowledge:write`; deleting an attached base additionally requires explicit `--force-detach` after approving the effect on those Stand-ins. These are destructive operations, so inspect the targets first and do not remove unrelated resources. The preview is a snapshot, not a revision guard: `--force-detach` removes all attachments present when deletion executes, including any added afterward. Stop if concurrent attachment changes make that effect uncertain; without force-detach, the server refuses deletion if attachments are present.
94
+
95
+ Multipart API operations remain available through `stand api … --form-file file=PATH`. Explicit bounded inspection is also available through the published API:
74
96
 
75
97
  ```sh
76
- stand api POST /v1/knowledge-bases/KNOWLEDGE_BASE_ID/uploads \
77
- --form-file file=/absolute/path/approved-document.pdf --yes --json
78
98
  stand api GET /v1/knowledge-bases/KNOWLEDGE_BASE_ID/tasks \
79
99
  --query-param view=inspection --query-param limit=20 --json
80
100
  ```
81
101
 
82
- Explicit knowledge inspection returns bounded metadata pages; follow `nextCursor` as `after` with the same filters. The ordinary knowledge list commands preserve existing full list responses. Inspect completed ingestion and documents before using newly uploaded knowledge.
102
+ Explicit knowledge inspection returns bounded metadata pages; follow `nextCursor` as `after` with the same filters. The ordinary knowledge list commands preserve existing full list responses. Attachment writes replace the full list; preserve attachments outside the authorized change.
83
103
 
84
104
  History export streams JSON to a new private file and validates the entire result before making it available. It never overwrites an existing file:
85
105
 
package/dist/commands.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { CliError, isObject, usage } from './errors.js';
3
3
  import { BUNDLED_SCHEMA } from './contract.js';
4
+ import { KNOWLEDGE_UPLOAD, multipartBody } from './multipart.js';
4
5
  const COMMAND_DEFINITIONS = [
5
6
  { "command": "org plan", "args": [], "description": "Read the current plan.", "scope": "org:read", "method": "GET", "path": "/v1/org/plan", "options": [], "confirmation": false, "bodyRequired": false },
6
7
  { "command": "org usage", "args": [], "description": "Read current plan usage.", "scope": "org:read", "method": "GET", "path": "/v1/org/usage", "options": [], "confirmation": false, "bodyRequired": false },
@@ -13,6 +14,20 @@ const COMMAND_DEFINITIONS = [
13
14
  { "command": "knowledge documents", "args": ["knowledgeBaseId"], "description": "Read indexed documents.", "scope": "knowledge:read", "method": "GET", "path": "/v1/knowledge-bases/{knowledgeBaseId}/documents", "options": [], "confirmation": false, "bodyRequired": false },
14
15
  { "command": "standins knowledge get", "args": ["standinProfileId"], "description": "Read knowledge attachments.", "scope": "knowledge:read", "method": "GET", "path": "/v1/reps/me/standins/{standinProfileId}/knowledge-bases", "options": [], "confirmation": false, "bodyRequired": false },
15
16
  { "command": "standins knowledge set", "args": ["standinProfileId"], "description": "Replace the complete approved attachment list.", "scope": "knowledge:write", "method": "PUT", "path": "/v1/reps/me/standins/{standinProfileId}/knowledge-bases", "options": ["body-file"], "confirmation": true, "bodyRequired": true },
17
+ { command: 'knowledge upload', args: ['knowledgeBaseId'], description: 'Upload .pdf, .docx, .md, .markdown or .txt, at most 10 MiB. Requires --file PATH; --source SOURCE_ID replaces an existing uploaded source.', scope: 'knowledge:write', method: 'POST', path: '/v1/knowledge-bases/{knowledgeBaseId}/uploads', options: ['file', 'source'], confirmation: true, upload: { fileOption: 'file', replacementSourceOption: 'source', ...KNOWLEDGE_UPLOAD } },
18
+ { command: 'knowledge sitemap', args: ['knowledgeBaseId'], description: 'Ingest an approved public sitemap; body contains sitemapUrl and optional dailyRefreshEnabled.', scope: 'knowledge:write', method: 'POST', path: '/v1/knowledge-bases/{knowledgeBaseId}/sitemaps', options: ['body-file'], confirmation: true, bodyRequired: true },
19
+ { command: 'knowledge update', args: ['knowledgeBaseId'], description: 'Replace name and description; read and preserve the description when renaming.', scope: 'knowledge:write', method: 'PUT', path: '/v1/knowledge-bases/{knowledgeBaseId}', options: ['body-file'], confirmation: true, bodyRequired: true },
20
+ { command: 'knowledge delete', args: ['knowledgeBaseId'], description: 'Show connected Stand-ins, then delete the base. --force-detach explicitly permits detaching them.', scope: 'knowledge:write', additionalScopes: ['knowledge:read'], method: 'DELETE', path: '/v1/knowledge-bases/{knowledgeBaseId}', options: ['force-detach'], confirmation: true },
21
+ { command: 'knowledge agents', args: ['knowledgeBaseId'], description: 'Read the Stand-ins connected to a knowledge base.', scope: 'knowledge:read', method: 'GET', path: '/v1/knowledge-bases/{knowledgeBaseId}/connected-agents', options: [] },
22
+ { command: 'knowledge search', args: ['knowledgeBaseId'], description: 'Search indexed knowledge with --query TEXT; --top-k is 1–20, default 5. Read-only.', scope: 'knowledge:read', method: 'POST', path: '/v1/knowledge-bases/{knowledgeBaseId}/search', options: ['query', 'top-k'], confirmation: false },
23
+ { command: 'knowledge sources refresh', args: ['sourceId'], description: 'Set daily sitemap refresh with --enabled true|false; existing plan gates apply.', scope: 'knowledge:write', method: 'PUT', path: '/v1/knowledge-bases/sources/{sourceId}/sitemap-refresh', options: ['enabled'], confirmation: true },
24
+ { command: 'knowledge sources recrawl', args: ['sourceId'], description: 'Recrawl an existing website or sitemap source.', scope: 'knowledge:write', method: 'POST', path: '/v1/knowledge-bases/sources/{sourceId}/recrawl', options: [], confirmation: true },
25
+ { command: 'knowledge sources delete', args: ['sourceId'], description: 'Delete a source and its indexed content.', scope: 'knowledge:write', method: 'DELETE', path: '/v1/knowledge-bases/sources/{sourceId}', options: [], confirmation: true },
26
+ { command: 'knowledge documents get', args: ['documentId'], description: 'Read an indexed document and its details.', scope: 'knowledge:read', method: 'GET', path: '/v1/knowledge-bases/documents/{documentId}', options: [] },
27
+ { command: 'knowledge documents chunks', args: ['documentId'], description: 'Read the indexed chunks of a document.', scope: 'knowledge:read', method: 'GET', path: '/v1/knowledge-bases/documents/{documentId}/chunks', options: [] },
28
+ { command: 'knowledge documents search', args: ['documentId'], description: 'Search one document with --query TEXT; --top-k is 1–20, default 5. Read-only.', scope: 'knowledge:read', method: 'POST', path: '/v1/knowledge-bases/documents/{documentId}/search', options: ['query', 'top-k'], confirmation: false },
29
+ { command: 'knowledge documents delete', args: ['documentId'], description: 'Delete one document and its indexed chunks.', scope: 'knowledge:write', method: 'DELETE', path: '/v1/knowledge-bases/documents/{documentId}', options: [], confirmation: true },
30
+ { command: 'knowledge tasks cancel', args: ['taskId'], description: 'Cancel a knowledge ingestion task.', scope: 'knowledge:write', method: 'POST', path: '/v1/knowledge-bases/tasks/{taskId}/cancel', options: [], confirmation: true },
16
31
  { command: 'standins master', args: [], description: 'Change the account-wide switch; affects other enabled Stand-ins.', scope: 'standins:write', method: 'POST', path: '/v1/reps/me/standin/toggle', options: ['enabled'], confirmation: true },
17
32
  { command: 'whoami', args: [], description: 'Get the authenticated user.', scope: 'profile:read', method: 'GET', path: '/v1/reps/me', options: [] },
18
33
  { command: 'profile get', args: [], description: 'Get your representative profile.', scope: 'profile:read', method: 'GET', path: '/v1/reps/profile', options: [] },
@@ -155,7 +170,9 @@ export async function requestBody(flags, stdin, signal) {
155
170
  return body;
156
171
  }
157
172
  export function findCommand(words) {
158
- const definition = COMMANDS.find(item => item.command.split(' ').every((word, i) => words[i] === word));
173
+ // Prefer a specific subcommand over an existing shorter list alias.
174
+ const definition = COMMANDS.filter(item => item.command.split(' ').every((word, i) => words[i] === word))
175
+ .sort((left, right) => right.command.length - left.command.length)[0];
159
176
  if (!definition)
160
177
  return undefined;
161
178
  const args = words.slice(definition.command.split(' ').length);
@@ -234,6 +251,28 @@ export async function executeCommand(client, command, args, flags, stdin, option
234
251
  const body = preparedBody ?? await requestBody(flags, stdin, options.signal);
235
252
  if (command.bodyRequired && !body)
236
253
  usage('This command requires --body-file FILE (or - for stdin). Run stand schema OPERATION_ID for its request schema.');
254
+ if (command.command === 'knowledge upload') {
255
+ const file = stringFlag(flags, 'file');
256
+ if (!file)
257
+ usage('Knowledge upload requires --file PATH.');
258
+ const source = stringFlag(flags, 'source');
259
+ if (source !== undefined && (!source || source === '.' || source === '..' || /[\x00-\x20\x7f/\\]/.test(source)))
260
+ usage('--source must be a nonempty source identifier.');
261
+ const path = resolvePath(command, args);
262
+ const multipart = await multipartBody(path, command.method, source === undefined ? [] : [`sourceId=${source}`], [`file=${file}`], options.signal);
263
+ return client.request(path, { ...options, method: command.method, multipart });
264
+ }
265
+ if (command.command === 'knowledge delete') {
266
+ const path = resolvePath(command, args);
267
+ return client.request(flags['force-detach'] ? `${path}?forceDetach=true` : path, { ...options, method: command.method });
268
+ }
269
+ if (command.command === 'knowledge search' || command.command === 'knowledge documents search') {
270
+ const query = stringFlag(flags, 'query');
271
+ if (!query?.trim())
272
+ usage('Knowledge search requires --query with nonempty text.');
273
+ return client.request(resolvePath(command, args), { ...options, method: command.method,
274
+ body: { query, topK: integerFlag(flags, 'top-k', 5, 1, 20) } });
275
+ }
237
276
  if (command.command === 'history export') {
238
277
  const file = stringFlag(flags, 'output');
239
278
  if (!file || file === '-')
@@ -285,7 +324,7 @@ export async function executeCommand(client, command, args, flags, stdin, option
285
324
  usage('--email must contain an email address.');
286
325
  return client.request(`${command.path}?${new URLSearchParams({ email })}`, options);
287
326
  }
288
- if (['standins set-enabled', 'standins master'].includes(command.command) || command.command === 'sites preferences update') {
327
+ if (['standins set-enabled', 'standins master', 'knowledge sources refresh'].includes(command.command) || command.command === 'sites preferences update') {
289
328
  const enabled = stringFlag(flags, 'enabled');
290
329
  if (enabled !== 'true' && enabled !== 'false')
291
330
  usage('--enabled must be true or false.');
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { link, lstat, mkdir, open, readdir, rename, rm } from 'node:fs/promises';
3
+ import { lstat, mkdir, open, readdir, rename, rm } from 'node:fs/promises';
4
4
  import { homedir } from 'node:os';
5
5
  import { isAbsolute, join } from 'node:path';
6
6
  import { spawn } from 'node:child_process';
@@ -82,39 +82,6 @@ export async function writePrivateJson(path, data) {
82
82
  await rm(temporary, { force: true });
83
83
  }
84
84
  }
85
- // Keep the observed inode alive until recovery finishes. A pathname can be
86
- // released and reused by another legacy client while we check the first PID.
87
- async function observeCredentialLock(path) {
88
- let file;
89
- try {
90
- file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
91
- const stat = await file.stat({ bigint: true });
92
- if (!stat.isFile() || stat.size > 128n * 1024n || (process.platform !== 'win32'
93
- && ((stat.mode & 63n) !== 0n || (process.getuid && stat.uid !== BigInt(process.getuid()))))) {
94
- throw new CliError('UNSAFE_CONFIG', 'Invalid credential lock type, size, ownership, or permissions.', 4);
95
- }
96
- const buffer = Buffer.alloc(Number(stat.size) + 1);
97
- const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
98
- let owner;
99
- // A size change or short read makes this observation opaque.
100
- if (BigInt(bytesRead) === stat.size) {
101
- try {
102
- owner = JSON.parse(buffer.toString('utf8', 0, bytesRead));
103
- }
104
- catch { /* Wait for an opaque owner. */ }
105
- }
106
- return { file, dev: stat.dev, ino: stat.ino, owner };
107
- }
108
- catch (error) {
109
- await file?.close();
110
- if (isObject(error) && error.code === 'ENOENT')
111
- return undefined;
112
- if (isObject(error) && error.code === 'ELOOP') {
113
- throw new CliError('UNSAFE_CONFIG', 'Credential locks must be regular files, not symbolic links.', 4);
114
- }
115
- throw error;
116
- }
117
- }
118
85
  async function withPrivateFileLock(root, path, resource, fn, signal) {
119
86
  const busyCode = resource === 'credential' ? 'CREDENTIAL_BUSY' : 'CONFIG_BUSY';
120
87
  const title = resource === 'credential' ? 'Credential' : 'Configuration';
@@ -126,12 +93,11 @@ async function withPrivateFileLock(root, path, resource, fn, signal) {
126
93
  await privateDirectory(queue);
127
94
  const id = `${process.pid}-${randomUUID()}.json`;
128
95
  const claim = join(queue, id);
129
- const candidate = `${claim}.lock`;
130
96
  const deadline = performance.now() + 120_000;
131
97
  const wait = async () => {
132
98
  cancelled();
133
99
  if (performance.now() >= deadline)
134
- throw new CliError(busyCode, `Another command owns this ${resource}, or its ownership cannot be verified. Retry after it finishes. Lock: ${path}; queue: ${queue}. Repair these paths only after verifying no Stand CLI process is running; never remove a live process’s lock.`, 4);
100
+ throw new CliError(busyCode, `Another command owns this ${resource}, or its ownership cannot be verified. Retry after it finishes. Queue: ${queue}. Repair this directory only after verifying no Stand CLI process is running; never remove a live process’s lock.`, 4);
135
101
  await sleep(50, signal);
136
102
  };
137
103
  const dead = (pid) => {
@@ -154,12 +120,11 @@ async function withPrivateFileLock(root, path, resource, fn, signal) {
154
120
  continue;
155
121
  if (!isObject(value) || !Number.isSafeInteger(value.pid) || Number(value.pid) <= 0
156
122
  || name.split('-')[0] !== String(value.pid) || !Number.isSafeInteger(value.ticket) || Number(value.ticket) < 0) {
157
- throw new CliError(busyCode, `${title} lock ownership is invalid. Lock: ${path}; queue: ${queue}. Verify no Stand CLI process is running before repairing these files.`, 4);
123
+ throw new CliError(busyCode, `${title} lock ownership is invalid. Queue: ${queue}. Verify no Stand CLI process is running before repairing these files.`, 4);
158
124
  }
159
125
  if (dead(Number(value.pid))) {
160
126
  // Names are unique and never reused. A competing cleanup cannot delete
161
127
  // another process's replacement claim as it could with a fixed lock path.
162
- await rm(`${file}.lock`, { force: true });
163
128
  await rm(file, { force: true });
164
129
  }
165
130
  else
@@ -167,12 +132,12 @@ async function withPrivateFileLock(root, path, resource, fn, signal) {
167
132
  }
168
133
  return active;
169
134
  };
170
- // Lamport's bakery arbitration serializes new clients before any stale
171
- // canonical-lock recovery. Publish choosing=0 atomically before inspecting
172
- // peers; new arrivals then observe us or wait for our ticket publication.
173
- let acquired = false;
174
- await writePrivateJson(claim, { pid: process.pid, ticket: 0 });
135
+ // Each attempt owns a unique claim for its whole lifetime, so stale cleanup
136
+ // never unlinks a new owner's claim. Publish choosing=0 atomically before
137
+ // inspecting peers; arrivals then observe us or wait for our ticket. The
138
+ // bakery queue is the only lock: there is no reusable PID-file pathname.
175
139
  try {
140
+ await writePrivateJson(claim, { pid: process.pid, ticket: 0 });
176
141
  const ticket = Math.max(0, ...(await claims()).map(peer => peer.ticket)) + 1;
177
142
  if (!Number.isSafeInteger(ticket))
178
143
  throw new CliError(busyCode, `${title} lock ticket limit reached.`, 4);
@@ -180,65 +145,11 @@ async function withPrivateFileLock(root, path, resource, fn, signal) {
180
145
  while ((await claims()).some(peer => peer.id !== id
181
146
  && (peer.ticket === 0 || peer.ticket < ticket || (peer.ticket === ticket && peer.id < id))))
182
147
  await wait();
183
- // Credential users retain the prior PID-file protocol for N-1 clients.
184
- // Configuration serialization requires all writers to use this new lock.
185
- // A complete candidate is linked exclusively, avoiding new empty locks
186
- // if this process dies between creation and writing its PID.
187
- const file = await open(candidate, 'wx', 0o600);
188
- try {
189
- await file.writeFile(String(process.pid));
190
- await file.sync();
191
- }
192
- finally {
193
- await file.close();
194
- }
195
- for (;;) {
196
- cancelled();
197
- try {
198
- await link(candidate, path);
199
- acquired = true;
200
- break;
201
- }
202
- catch (error) {
203
- if (!isObject(error) || error.code !== 'EEXIST')
204
- throw error;
205
- }
206
- const observed = await observeCredentialLock(path);
207
- try {
208
- if (observed && Number.isSafeInteger(observed.owner) && Number(observed.owner) > 0 && dead(Number(observed.owner))) {
209
- const current = await observeCredentialLock(path);
210
- try {
211
- // Holding the original descriptor prevents inode reuse. Only
212
- // remove that same dead owner's lock, never a replacement owner.
213
- // Bakery arbitration excludes another new-client recoverer;
214
- // legacy clients never remove a lock owned by another process.
215
- if (current && current.dev === observed.dev && current.ino === observed.ino && current.owner === observed.owner) {
216
- cancelled();
217
- await rm(path, { force: true });
218
- }
219
- }
220
- finally {
221
- await current?.file.close();
222
- }
223
- }
224
- }
225
- finally {
226
- await observed?.file.close();
227
- }
228
- await wait();
229
- }
230
148
  cancelled();
231
149
  return await fn();
232
150
  }
233
151
  finally {
234
- try {
235
- if (acquired)
236
- await rm(path, { force: true });
237
- }
238
- finally {
239
- await rm(candidate, { force: true });
240
- await rm(claim, { force: true });
241
- }
152
+ await rm(claim, { force: true });
242
153
  }
243
154
  }
244
155
  export class ConfigStore {
@@ -290,7 +201,6 @@ export class ConfigStore {
290
201
  }
291
202
  // Every mutation of the shared file must derive its result after acquiring
292
203
  // this global lock. A per-profile lock cannot protect the other profile rows.
293
- // Older CLI versions do not take this lock and must not write concurrently.
294
204
  async update(name, change, signal) {
295
205
  const selected = profileName(name);
296
206
  return withPrivateFileLock(this.root, join(this.root, 'config.json.lock'), 'configuration', async () => {
package/dist/index.js CHANGED
@@ -6,15 +6,15 @@ import { realpathSync } from 'node:fs';
6
6
  import { CLI_VERSION } from './version.js';
7
7
  import { AuthSession, ALLOWED_SCOPES, DEFAULT_SCOPES, SETUP_SCOPES, deviceLogin, scopesForLogin } from './auth.js';
8
8
  import { ConfigStore, CredentialVault, profileName } from './config.js';
9
- import { COMMANDS, executeCommand, findCommand, integerFlag, readInput, readSchema, requestBody, stringFlag } from './commands.js';
9
+ import { COMMANDS, executeCommand, findCommand, integerFlag, readInput, readSchema, requestBody, resolvePath, stringFlag } from './commands.js';
10
10
  import { CliError, isObject, terminalJson, terminalText, usage } from './errors.js';
11
11
  import { multipartBody } from './multipart.js';
12
12
  import { publicOperation } from './contract.js';
13
13
  import { setupStatus } from './setup.js';
14
14
  import { ApiClient, apiUrl, safeUrl, sleep } from './transport.js';
15
15
  const GLOBAL_OPTIONS = ['profile', 'json', 'no-input', 'yes', 'help', 'timeout', 'retries'];
16
- const STRING_OPTIONS = ['profile', 'timeout', 'retries', 'api-url', 'issuer', 'scopes', 'credential-store', 'body-file', 'domain', 'name', 'enabled', 'language', 'query', 'page', 'size', 'type', 'scope', 'site', 'contact', 'label', 'closed-from', 'closed-to', 'sort-by', 'sort-dir', 'max-pages', 'interval', 'count', 'email', 'output', 'max-bytes', 'app-url'];
17
- const BOOLEAN_OPTIONS = ['json', 'no-input', 'yes', 'help', 'all', 'watch', 'token-stdin', 'local-only', 'check', 'version', 'setup'];
16
+ const STRING_OPTIONS = ['profile', 'timeout', 'retries', 'api-url', 'issuer', 'scopes', 'credential-store', 'body-file', 'domain', 'name', 'enabled', 'language', 'query', 'page', 'size', 'type', 'scope', 'site', 'contact', 'label', 'closed-from', 'closed-to', 'sort-by', 'sort-dir', 'max-pages', 'interval', 'count', 'email', 'output', 'max-bytes', 'app-url', 'file', 'source', 'top-k'];
17
+ const BOOLEAN_OPTIONS = ['json', 'no-input', 'yes', 'help', 'all', 'watch', 'token-stdin', 'local-only', 'check', 'version', 'setup', 'force-detach'];
18
18
  const HELP = `Stand Chat CLI (beta)\n\nUsage: stand <resource> <command> [arguments] [options]\n\n` +
19
19
  COMMANDS.map(command => ` ${`${command.command}${command.args.map(arg => ` <${arg}>`).join(' ')}`.padEnd(34)} ${command.description}`).join('\n') +
20
20
  `\n\n auth login Sign in using a browser and device code; read-only scopes by default.\n` +
@@ -73,7 +73,7 @@ const LEGACY_EMBED_WARNING = 'Legacy embedOptions merges option keys but replace
73
73
  function legacyEmbedWarning(body) {
74
74
  return body && isObject(body.embedOptions) ? LEGACY_EMBED_WARNING : undefined;
75
75
  }
76
- async function confirm(label, flags, signal, warning) {
76
+ async function confirm(label, flags, signal, warning, notSent = 'No request was sent.') {
77
77
  if (flags.yes) {
78
78
  if (warning)
79
79
  process.stderr.write(Boolean(flags.json) || !process.stdout.isTTY
@@ -84,7 +84,7 @@ async function confirm(label, flags, signal, warning) {
84
84
  if (warning)
85
85
  label = `${label}. ${warning}`;
86
86
  if (flags['no-input'] || !process.stdin.isTTY || !process.stderr.isTTY || flags['body-file'] === '-') {
87
- throw new CliError('CONFIRMATION_REQUIRED', `${label} requires --yes. No request was sent.`, 2);
87
+ throw new CliError('CONFIRMATION_REQUIRED', `${label} requires --yes. ${notSent}`, 2);
88
88
  }
89
89
  const prompt = createInterface({ input: process.stdin, output: process.stderr });
90
90
  const cancellation = new AbortController();
@@ -97,11 +97,11 @@ async function confirm(label, flags, signal, warning) {
97
97
  try {
98
98
  const answer = await prompt.question(`${terminalText(label)}? Type yes to continue: `, { signal: cancellation.signal });
99
99
  if (answer !== 'yes')
100
- throw new CliError('CANCELLED', 'Operation cancelled. No request was sent.', 130);
100
+ throw new CliError('CANCELLED', `Operation cancelled. ${notSent}`, 130);
101
101
  }
102
102
  catch (error) {
103
103
  if (cancellation.signal.aborted)
104
- throw new CliError('CANCELLED', 'Operation cancelled. No request was sent.', 130);
104
+ throw new CliError('CANCELLED', `Operation cancelled. ${notSent}`, 130);
105
105
  throw error;
106
106
  }
107
107
  finally {
@@ -310,8 +310,27 @@ export async function main(args = process.argv.slice(2), env = process.env, sign
310
310
  usage('Unknown command. Run stand --help or stand commands --json.');
311
311
  checkOptions(flags, match.definition.options);
312
312
  const preparedBody = match.definition.command === 'sites update' ? await requestBody(flags, process.stdin, signal) : undefined;
313
+ let confirmationLabel = `${match.definition.command} ${match.args.join(' ')}`.trim();
314
+ const knowledgeDelete = match.definition.command === 'knowledge delete';
315
+ if (knowledgeDelete) {
316
+ // Keep the no-request guarantee for noninteractive invocations without --yes.
317
+ if (!flags.yes && (flags['no-input'] || !process.stdin.isTTY || !process.stderr.isTTY))
318
+ await confirm(confirmationLabel, flags, signal);
319
+ const connected = await client.request(`${resolvePath(match.definition, match.args)}/connected-agents`, options);
320
+ if (!isObject(connected) || !Array.isArray(connected.agents) || connected.agents.some(agent => !isObject(agent) || typeof agent.standinProfileId !== 'string' || !agent.standinProfileId.trim()
321
+ || typeof agent.name !== 'string' || typeof agent.displayName !== 'string')) {
322
+ throw new CliError('INVALID_RESPONSE', 'Could not inspect connected Stand-ins. No delete request was sent.', 5);
323
+ }
324
+ process.stderr.write(`${terminalJson({ event: 'knowledge_delete_preview', knowledgeBaseId: match.args[0],
325
+ agents: connected.agents, forceDetach: Boolean(flags['force-detach']) }, !json)}\n`);
326
+ if (connected.agents.length && !flags['force-detach']) {
327
+ throw new CliError('KNOWLEDGE_ATTACHED', 'This knowledge base is connected to Stand-ins. Review the connected agents, then use --force-detach only if their detachment is authorized. No delete request was sent.', 2);
328
+ }
329
+ if (flags['force-detach'])
330
+ confirmationLabel += ` and detach all connected Stand-ins (${connected.agents.length} currently listed)`;
331
+ }
313
332
  if (match.definition.confirmation)
314
- await confirm(`${match.definition.command} ${match.args.join(' ')}`.trim(), flags, signal, legacyEmbedWarning(preparedBody));
333
+ await confirm(confirmationLabel, flags, signal, legacyEmbedWarning(preparedBody), knowledgeDelete ? 'No delete request was sent.' : undefined);
315
334
  if ((flags.interval !== undefined || flags.count !== undefined) && !flags.watch)
316
335
  usage('--interval and --count require --watch.');
317
336
  const count = flags.watch ? integerFlag(flags, 'count', Number.MAX_SAFE_INTEGER, 1, 1_000_000) : 1;
package/dist/multipart.js CHANGED
@@ -3,6 +3,10 @@ import { open } from 'node:fs/promises';
3
3
  import { basename, extname } from 'node:path';
4
4
  import { CliError, isObject, usage } from './errors.js';
5
5
  import { publicOperation } from './contract.js';
6
+ export const KNOWLEDGE_UPLOAD = {
7
+ extensions: ['.pdf', '.docx', '.md', '.markdown', '.txt'],
8
+ maxFileBytes: 10 * 1024 * 1024,
9
+ };
6
10
  const MAX_TEXT_BYTES = 1024 * 1024;
7
11
  const MAX_FORM_BYTES = 11 * 1024 * 1024; // Leave room inside the server's 12 MiB multipart envelope.
8
12
  const IMAGE_UPLOAD_LIMITS = {
@@ -70,13 +74,14 @@ export async function multipartBody(path, method, strings, files, signal) {
70
74
  const contentType = MIME[extname(filename).toLowerCase()] ?? 'application/octet-stream';
71
75
  const feedback = operation.operationId === 'submitFeedback';
72
76
  const imageLimit = typeof operation.operationId === 'string' ? IMAGE_UPLOAD_LIMITS[operation.operationId] : undefined;
73
- if (operation.operationId === 'uploadKnowledgeBaseSource' && !['.pdf', '.md', '.markdown', '.txt', '.docx'].includes(extname(filename).toLowerCase()))
77
+ if (operation.operationId === 'uploadKnowledgeBaseSource' && !KNOWLEDGE_UPLOAD.extensions.some(extension => extension === extname(filename).toLowerCase()))
74
78
  usage('Knowledge uploads support PDF, Markdown, text and Word .docx files.');
75
79
  if (feedback && !contentType.startsWith('image/'))
76
80
  usage('Feedback attachments must use a .png, .jpg, .jpeg, .webp or .gif image filename.');
77
81
  if (imageLimit && !contentType.startsWith('image/'))
78
82
  usage('Image uploads must use a .png, .jpg, .jpeg, .webp or .gif filename.');
79
- const limit = (feedback ? 4 : imageLimit ?? 10) * 1024 * 1024;
83
+ const limit = operation.operationId === 'uploadKnowledgeBaseSource' ? KNOWLEDGE_UPLOAD.maxFileBytes
84
+ : (feedback ? 4 : imageLimit ?? 10) * 1024 * 1024;
80
85
  let handle;
81
86
  try {
82
87
  handle = await open(part.input, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));