@indigoai-us/hq-cli 5.119.9 → 5.119.11
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/CHANGELOG.md +30 -0
- package/dist/command-catalog.generated.d.ts +54 -1
- package/dist/command-catalog.generated.js +71 -1
- package/dist/commands/skill.d.ts +20 -1
- package/dist/commands/skill.js +250 -15
- package/dist/lib/plan-limit-nag.d.ts +22 -0
- package/dist/lib/plan-limit-nag.js +36 -0
- package/dist/utils/plan-gate-error.d.ts +8 -4
- package/dist/utils/plan-gate-error.js +36 -1
- package/dist/utils/vault-api.d.ts +1 -0
- package/dist/utils/vault-api.js +24 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.119.11] — 2026-09-18
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- On a personal account, My Telemetry is part of the paid Individual plan
|
|
10
|
+
($50/mo). When the server withholds it, the CLI now prints a short notice
|
|
11
|
+
naming the plan and where to upgrade, instead of the raw refusal. Company
|
|
12
|
+
plans are unaffected, and the notice has nothing to do with the plan-limit
|
|
13
|
+
nags — it is a plan fact, printed once, where it happened.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- `hq files … --company prs_<person-uid>` now reaches that person's vault
|
|
18
|
+
instead of failing with "Company slug ... was not found". This is how a DM
|
|
19
|
+
recipient reads a file attached to a message: the attachment lives in the
|
|
20
|
+
sender's personal vault, so naming the sender is the only scope that works.
|
|
21
|
+
|
|
22
|
+
## [5.119.10] — 2026-09-18
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `hq skill register` reserves a company skill UID from a proposal-lane
|
|
27
|
+
source and writes the server-stamped bytes only to `--stamped-output`.
|
|
28
|
+
It does not write `companies/<company>/skills/`, create a discovery
|
|
29
|
+
wrapper, or sync. `hq skill promote` copies those exact bytes into the
|
|
30
|
+
canonical skill path only after `--expected-sha256` matches and the
|
|
31
|
+
stamped `skill_uid` matches the server registration. A differing
|
|
32
|
+
canonical file requires `--reviewed-update`. Use `--no-sync` to leave
|
|
33
|
+
upload to a later steward command.
|
|
34
|
+
|
|
5
35
|
## [5.119.9] — 2026-09-17
|
|
6
36
|
|
|
7
37
|
### Added
|
|
@@ -1877,7 +1877,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
1877
1877
|
readonly subcommands: readonly [];
|
|
1878
1878
|
}, {
|
|
1879
1879
|
readonly name: "skill";
|
|
1880
|
-
readonly description: "Create
|
|
1880
|
+
readonly description: "Create, stage, promote, and discuss company skills";
|
|
1881
1881
|
readonly aliases: readonly [];
|
|
1882
1882
|
readonly hidden: false;
|
|
1883
1883
|
readonly usage: "[options] [command]";
|
|
@@ -1914,6 +1914,59 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
1914
1914
|
readonly description: "Refresh local skill discovery without registration or sync";
|
|
1915
1915
|
}];
|
|
1916
1916
|
readonly subcommands: readonly [];
|
|
1917
|
+
}, {
|
|
1918
|
+
readonly name: "register";
|
|
1919
|
+
readonly description: "Register a company skill to a proposal-lane stamped file without writing the canonical tree";
|
|
1920
|
+
readonly aliases: readonly [];
|
|
1921
|
+
readonly hidden: false;
|
|
1922
|
+
readonly usage: "[options] <slug>";
|
|
1923
|
+
readonly arguments: readonly [{
|
|
1924
|
+
readonly name: "slug";
|
|
1925
|
+
readonly required: true;
|
|
1926
|
+
readonly variadic: false;
|
|
1927
|
+
}];
|
|
1928
|
+
readonly options: readonly [{
|
|
1929
|
+
readonly flags: "--source <path>";
|
|
1930
|
+
readonly description: "Caller-authored SKILL.md (or its directory) to send to the server validator";
|
|
1931
|
+
}, {
|
|
1932
|
+
readonly flags: "--stamped-output <path>";
|
|
1933
|
+
readonly description: "Write the complete server-stamped bytes here, outside companies/<company>/skills/";
|
|
1934
|
+
}, {
|
|
1935
|
+
readonly flags: "--no-canonical-write";
|
|
1936
|
+
readonly description: "Refuse to write companies/<company>/skills/ (always the register behavior)";
|
|
1937
|
+
}, {
|
|
1938
|
+
readonly flags: "--no-surface";
|
|
1939
|
+
readonly description: "Do not create a discovery wrapper (always the register behavior)";
|
|
1940
|
+
}, {
|
|
1941
|
+
readonly flags: "--no-sync";
|
|
1942
|
+
readonly description: "Do not upload the skill file (always the register behavior)";
|
|
1943
|
+
}];
|
|
1944
|
+
readonly subcommands: readonly [];
|
|
1945
|
+
}, {
|
|
1946
|
+
readonly name: "promote";
|
|
1947
|
+
readonly description: "Copy independently-cleared stamped bytes into the canonical skill path after SHA-256 and skill_uid checks";
|
|
1948
|
+
readonly aliases: readonly [];
|
|
1949
|
+
readonly hidden: false;
|
|
1950
|
+
readonly usage: "[options] <slug>";
|
|
1951
|
+
readonly arguments: readonly [{
|
|
1952
|
+
readonly name: "slug";
|
|
1953
|
+
readonly required: true;
|
|
1954
|
+
readonly variadic: false;
|
|
1955
|
+
}];
|
|
1956
|
+
readonly options: readonly [{
|
|
1957
|
+
readonly flags: "--from <path>";
|
|
1958
|
+
readonly description: "Independently-cleared stamped SKILL.md (or its directory)";
|
|
1959
|
+
}, {
|
|
1960
|
+
readonly flags: "--expected-sha256 <hex>";
|
|
1961
|
+
readonly description: "SHA-256 of the exact stamped file bytes that were cleared";
|
|
1962
|
+
}, {
|
|
1963
|
+
readonly flags: "--reviewed-update";
|
|
1964
|
+
readonly description: "Replace an existing canonical SKILL.md whose bytes differ from the stamped file";
|
|
1965
|
+
}, {
|
|
1966
|
+
readonly flags: "--no-sync";
|
|
1967
|
+
readonly description: "Promote locally without uploading the canonical file";
|
|
1968
|
+
}];
|
|
1969
|
+
readonly subcommands: readonly [];
|
|
1917
1970
|
}, {
|
|
1918
1971
|
readonly name: "propose";
|
|
1919
1972
|
readonly description: "Post a comment-only improvement for a skill";
|
|
@@ -2403,7 +2403,7 @@ export const COMMAND_CATALOG = [
|
|
|
2403
2403
|
},
|
|
2404
2404
|
{
|
|
2405
2405
|
"name": "skill",
|
|
2406
|
-
"description": "Create
|
|
2406
|
+
"description": "Create, stage, promote, and discuss company skills",
|
|
2407
2407
|
"aliases": [],
|
|
2408
2408
|
"hidden": false,
|
|
2409
2409
|
"usage": "[options] [command]",
|
|
@@ -2452,6 +2452,76 @@ export const COMMAND_CATALOG = [
|
|
|
2452
2452
|
],
|
|
2453
2453
|
"subcommands": []
|
|
2454
2454
|
},
|
|
2455
|
+
{
|
|
2456
|
+
"name": "register",
|
|
2457
|
+
"description": "Register a company skill to a proposal-lane stamped file without writing the canonical tree",
|
|
2458
|
+
"aliases": [],
|
|
2459
|
+
"hidden": false,
|
|
2460
|
+
"usage": "[options] <slug>",
|
|
2461
|
+
"arguments": [
|
|
2462
|
+
{
|
|
2463
|
+
"name": "slug",
|
|
2464
|
+
"required": true,
|
|
2465
|
+
"variadic": false
|
|
2466
|
+
}
|
|
2467
|
+
],
|
|
2468
|
+
"options": [
|
|
2469
|
+
{
|
|
2470
|
+
"flags": "--source <path>",
|
|
2471
|
+
"description": "Caller-authored SKILL.md (or its directory) to send to the server validator"
|
|
2472
|
+
},
|
|
2473
|
+
{
|
|
2474
|
+
"flags": "--stamped-output <path>",
|
|
2475
|
+
"description": "Write the complete server-stamped bytes here, outside companies/<company>/skills/"
|
|
2476
|
+
},
|
|
2477
|
+
{
|
|
2478
|
+
"flags": "--no-canonical-write",
|
|
2479
|
+
"description": "Refuse to write companies/<company>/skills/ (always the register behavior)"
|
|
2480
|
+
},
|
|
2481
|
+
{
|
|
2482
|
+
"flags": "--no-surface",
|
|
2483
|
+
"description": "Do not create a discovery wrapper (always the register behavior)"
|
|
2484
|
+
},
|
|
2485
|
+
{
|
|
2486
|
+
"flags": "--no-sync",
|
|
2487
|
+
"description": "Do not upload the skill file (always the register behavior)"
|
|
2488
|
+
}
|
|
2489
|
+
],
|
|
2490
|
+
"subcommands": []
|
|
2491
|
+
},
|
|
2492
|
+
{
|
|
2493
|
+
"name": "promote",
|
|
2494
|
+
"description": "Copy independently-cleared stamped bytes into the canonical skill path after SHA-256 and skill_uid checks",
|
|
2495
|
+
"aliases": [],
|
|
2496
|
+
"hidden": false,
|
|
2497
|
+
"usage": "[options] <slug>",
|
|
2498
|
+
"arguments": [
|
|
2499
|
+
{
|
|
2500
|
+
"name": "slug",
|
|
2501
|
+
"required": true,
|
|
2502
|
+
"variadic": false
|
|
2503
|
+
}
|
|
2504
|
+
],
|
|
2505
|
+
"options": [
|
|
2506
|
+
{
|
|
2507
|
+
"flags": "--from <path>",
|
|
2508
|
+
"description": "Independently-cleared stamped SKILL.md (or its directory)"
|
|
2509
|
+
},
|
|
2510
|
+
{
|
|
2511
|
+
"flags": "--expected-sha256 <hex>",
|
|
2512
|
+
"description": "SHA-256 of the exact stamped file bytes that were cleared"
|
|
2513
|
+
},
|
|
2514
|
+
{
|
|
2515
|
+
"flags": "--reviewed-update",
|
|
2516
|
+
"description": "Replace an existing canonical SKILL.md whose bytes differ from the stamped file"
|
|
2517
|
+
},
|
|
2518
|
+
{
|
|
2519
|
+
"flags": "--no-sync",
|
|
2520
|
+
"description": "Promote locally without uploading the canonical file"
|
|
2521
|
+
}
|
|
2522
|
+
],
|
|
2523
|
+
"subcommands": []
|
|
2524
|
+
},
|
|
2455
2525
|
{
|
|
2456
2526
|
"name": "propose",
|
|
2457
2527
|
"description": "Post a comment-only improvement for a skill",
|
package/dist/commands/skill.d.ts
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `hq skill create <slug>` registers a canonical company skill, stamps its
|
|
5
5
|
* immutable UID, surfaces its generated runtime wrapper, and syncs it.
|
|
6
|
+
* `hq skill register <slug>` reserves that UID from a proposal-lane source and
|
|
7
|
+
* writes server-stamped bytes only to `--stamped-output` — never under
|
|
8
|
+
* `companies/<company>/skills/`, and never as a discovery wrapper or sync.
|
|
9
|
+
* `hq skill promote <slug>` copies independently-cleared stamped bytes into the
|
|
10
|
+
* canonical tree only after the expected SHA-256 and registered skill_uid match.
|
|
6
11
|
* `hq skill propose <uid|path> --message "…"` posts a whole-skill comment to
|
|
7
12
|
* the same improvement thread shown in HQ Console. It never uploads a modified
|
|
8
13
|
* SKILL.md and cannot overwrite live content. `hq skill delete <target>` removes
|
|
@@ -39,7 +44,21 @@ export declare function makeSkillTemplate(input: {
|
|
|
39
44
|
description?: string;
|
|
40
45
|
}): string;
|
|
41
46
|
/** Replace SKILL.md without exposing a partially-written identity to agents. */
|
|
42
|
-
export declare function writeSkillFileAtomically(filePath: string, content: string): void;
|
|
47
|
+
export declare function writeSkillFileAtomically(filePath: string, content: string | Buffer): void;
|
|
48
|
+
export declare function sha256Hex(data: string | Buffer): string;
|
|
49
|
+
export declare function skillVaultPath(skillSlug: string): string;
|
|
50
|
+
export declare function isInsideCompanySkillsTree(hqRoot: string, candidatePath: string): boolean;
|
|
51
|
+
export declare function resolveSkillMarkdownPath(target: string, cwd: string): string;
|
|
52
|
+
export declare function assertStagedOutputPath(hqRoot: string, outputPath: string): string;
|
|
53
|
+
export declare function assertSha256HexMatch(actualHex: string, expectedHex: string): void;
|
|
54
|
+
export declare function assertRegisteredSkillResponse(registered: {
|
|
55
|
+
skillUid?: unknown;
|
|
56
|
+
content?: unknown;
|
|
57
|
+
}): {
|
|
58
|
+
skillUid: string;
|
|
59
|
+
content: string;
|
|
60
|
+
};
|
|
61
|
+
export declare function normalizeLiveSkillVaultPath(rawPath: string): string;
|
|
43
62
|
export declare function mapSkillError(status: number, body: Record<string, unknown>): string;
|
|
44
63
|
/**
|
|
45
64
|
* Type a failed skills-API response by its HTTP status so the top-level error
|
package/dist/commands/skill.js
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `hq skill create <slug>` registers a canonical company skill, stamps its
|
|
5
5
|
* immutable UID, surfaces its generated runtime wrapper, and syncs it.
|
|
6
|
+
* `hq skill register <slug>` reserves that UID from a proposal-lane source and
|
|
7
|
+
* writes server-stamped bytes only to `--stamped-output` — never under
|
|
8
|
+
* `companies/<company>/skills/`, and never as a discovery wrapper or sync.
|
|
9
|
+
* `hq skill promote <slug>` copies independently-cleared stamped bytes into the
|
|
10
|
+
* canonical tree only after the expected SHA-256 and registered skill_uid match.
|
|
6
11
|
* `hq skill propose <uid|path> --message "…"` posts a whole-skill comment to
|
|
7
12
|
* the same improvement thread shown in HQ Console. It never uploads a modified
|
|
8
13
|
* SKILL.md and cannot overwrite live content. `hq skill delete <target>` removes
|
|
@@ -11,6 +16,7 @@
|
|
|
11
16
|
*/
|
|
12
17
|
import * as fs from "node:fs";
|
|
13
18
|
import * as path from "node:path";
|
|
19
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
14
20
|
import chalk from "chalk";
|
|
15
21
|
import yaml from "js-yaml";
|
|
16
22
|
import { share } from "@indigoai-us/hq-cloud";
|
|
@@ -25,6 +31,7 @@ export const SKILL_UID_PATTERN = /^skl_[A-Za-z0-9]+$/;
|
|
|
25
31
|
export const SKILL_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
26
32
|
const COMPANY_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
27
33
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
34
|
+
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
|
|
28
35
|
async function defaultSyncFile(input) {
|
|
29
36
|
const result = await share({
|
|
30
37
|
paths: [input.filePath],
|
|
@@ -185,11 +192,19 @@ export function writeSkillFileAtomically(filePath, content) {
|
|
|
185
192
|
: 0o644;
|
|
186
193
|
const tempPath = path.join(dir, `.SKILL.md.${process.pid}.${Date.now()}.tmp`);
|
|
187
194
|
try {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
195
|
+
if (typeof content === "string") {
|
|
196
|
+
fs.writeFileSync(tempPath, content, {
|
|
197
|
+
encoding: "utf8",
|
|
198
|
+
flag: "wx",
|
|
199
|
+
mode: existingMode,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
fs.writeFileSync(tempPath, content, {
|
|
204
|
+
flag: "wx",
|
|
205
|
+
mode: existingMode,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
193
208
|
fs.renameSync(tempPath, filePath);
|
|
194
209
|
}
|
|
195
210
|
finally {
|
|
@@ -197,6 +212,69 @@ export function writeSkillFileAtomically(filePath, content) {
|
|
|
197
212
|
fs.rmSync(tempPath, { force: true });
|
|
198
213
|
}
|
|
199
214
|
}
|
|
215
|
+
export function sha256Hex(data) {
|
|
216
|
+
return createHash("sha256").update(data).digest("hex");
|
|
217
|
+
}
|
|
218
|
+
export function skillVaultPath(skillSlug) {
|
|
219
|
+
return `skills/${skillSlug}/SKILL.md`;
|
|
220
|
+
}
|
|
221
|
+
export function isInsideCompanySkillsTree(hqRoot, candidatePath) {
|
|
222
|
+
const companiesRoot = path.resolve(hqRoot, "companies");
|
|
223
|
+
const resolved = path.resolve(candidatePath);
|
|
224
|
+
const rel = path.relative(companiesRoot, resolved);
|
|
225
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
|
|
226
|
+
return false;
|
|
227
|
+
const parts = rel.split(path.sep);
|
|
228
|
+
return parts.length >= 2 && parts[1] === "skills";
|
|
229
|
+
}
|
|
230
|
+
export function resolveSkillMarkdownPath(target, cwd) {
|
|
231
|
+
let filePath = path.resolve(cwd, target);
|
|
232
|
+
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
|
233
|
+
filePath = path.join(filePath, "SKILL.md");
|
|
234
|
+
}
|
|
235
|
+
return filePath;
|
|
236
|
+
}
|
|
237
|
+
export function assertStagedOutputPath(hqRoot, outputPath) {
|
|
238
|
+
const resolved = path.resolve(outputPath);
|
|
239
|
+
if (isInsideCompanySkillsTree(hqRoot, resolved)) {
|
|
240
|
+
throw localSkillError(`Stamped output '${resolved}' is inside companies/<company>/skills/. Pass a proposal-lane path outside the canonical skill tree.`);
|
|
241
|
+
}
|
|
242
|
+
return resolved;
|
|
243
|
+
}
|
|
244
|
+
export function assertSha256HexMatch(actualHex, expectedHex) {
|
|
245
|
+
const want = expectedHex.trim().toLowerCase();
|
|
246
|
+
if (!SHA256_HEX_PATTERN.test(want)) {
|
|
247
|
+
throw localSkillError("--expected-sha256 must be a 64-character hex SHA-256 digest.");
|
|
248
|
+
}
|
|
249
|
+
const got = actualHex.trim().toLowerCase();
|
|
250
|
+
if (!SHA256_HEX_PATTERN.test(got)) {
|
|
251
|
+
throw localSkillError(`Stamped file SHA-256 ${got} does not match --expected-sha256. Canonical skill was not written.`);
|
|
252
|
+
}
|
|
253
|
+
if (!timingSafeEqual(Buffer.from(got, "hex"), Buffer.from(want, "hex"))) {
|
|
254
|
+
throw localSkillError(`Stamped file SHA-256 ${got} does not match --expected-sha256. Canonical skill was not written.`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
export function assertRegisteredSkillResponse(registered) {
|
|
258
|
+
if (typeof registered.skillUid !== "string" ||
|
|
259
|
+
!SKILL_UID_PATTERN.test(registered.skillUid) ||
|
|
260
|
+
typeof registered.content !== "string" ||
|
|
261
|
+
parseSkillUid(registered.content) !== registered.skillUid) {
|
|
262
|
+
throw new Error("The server returned an invalid skill registration response; the local file was not changed.");
|
|
263
|
+
}
|
|
264
|
+
return { skillUid: registered.skillUid, content: registered.content };
|
|
265
|
+
}
|
|
266
|
+
function skillRecordPath(record) {
|
|
267
|
+
for (const value of [record?.vaultPath, record?.sourcePath, record?.path]) {
|
|
268
|
+
if (typeof value === "string" && value.length > 0)
|
|
269
|
+
return value;
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
export function normalizeLiveSkillVaultPath(rawPath) {
|
|
274
|
+
const normalized = rawPath.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
|
|
275
|
+
const match = normalized.match(/(?:^|\/)skills\/([a-z0-9][a-z0-9-]*)(?:\/SKILL\.md)?$/);
|
|
276
|
+
return match ? skillVaultPath(match[1]) : normalized;
|
|
277
|
+
}
|
|
200
278
|
export function mapSkillError(status, body) {
|
|
201
279
|
const server = (typeof body.error === "string" && body.error) ||
|
|
202
280
|
(typeof body.message === "string" && body.message) ||
|
|
@@ -360,7 +438,7 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
360
438
|
const resolveCompanyUid = deps.resolveCompanyUid ?? getCompanyUid;
|
|
361
439
|
const skill = program
|
|
362
440
|
.command("skill")
|
|
363
|
-
.description("Create
|
|
441
|
+
.description("Create, stage, promote, and discuss company skills")
|
|
364
442
|
.option("--company <slug>", "Company slug (defaults to the active company)")
|
|
365
443
|
.option("--hq-root <path>", "Local HQ root", hqRoot);
|
|
366
444
|
skill
|
|
@@ -420,7 +498,7 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
420
498
|
path: `/v1/files/skills/company/${encodeURIComponent(companySlug)}/register`,
|
|
421
499
|
method: "POST",
|
|
422
500
|
body: {
|
|
423
|
-
path:
|
|
501
|
+
path: skillVaultPath(slug),
|
|
424
502
|
content: localContent,
|
|
425
503
|
},
|
|
426
504
|
});
|
|
@@ -430,13 +508,8 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
430
508
|
machineIdentity: isMachineIdentity(),
|
|
431
509
|
});
|
|
432
510
|
}
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
!SKILL_UID_PATTERN.test(registered.skillUid) ||
|
|
436
|
-
typeof registered.content !== "string" ||
|
|
437
|
-
parseSkillUid(registered.content) !== registered.skillUid) {
|
|
438
|
-
throw new Error("The server returned an invalid skill registration response; the local file was not changed.");
|
|
439
|
-
}
|
|
511
|
+
const payload = (await response.json());
|
|
512
|
+
const registered = assertRegisteredSkillResponse(payload);
|
|
440
513
|
writeSkillFileAtomically(filePath, registered.content);
|
|
441
514
|
let discoveryStatus = null;
|
|
442
515
|
try {
|
|
@@ -506,11 +579,173 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
506
579
|
console.log(chalk.green(`Skill ready: ${registered.skillUid}`));
|
|
507
580
|
console.log(` File: ${filePath}`);
|
|
508
581
|
console.log(` Discovery: ${discoveryStatus === 0 ? "ready" : "needs attention"}`);
|
|
509
|
-
console.log(
|
|
582
|
+
console.log(payload.accessPolicy === "open"
|
|
510
583
|
? ` Access: Open — every active ${companySlug} member can edit`
|
|
511
584
|
: " Access: preserved existing policy");
|
|
512
585
|
console.log(` Sync: ${opts.sync === false ? "not requested" : "complete"}`);
|
|
513
586
|
});
|
|
587
|
+
skill
|
|
588
|
+
.command("register <slug>")
|
|
589
|
+
.description("Register a company skill to a proposal-lane stamped file without writing the canonical tree")
|
|
590
|
+
.requiredOption("--source <path>", "Caller-authored SKILL.md (or its directory) to send to the server validator")
|
|
591
|
+
.requiredOption("--stamped-output <path>", "Write the complete server-stamped bytes here, outside companies/<company>/skills/")
|
|
592
|
+
.option("--no-canonical-write", "Refuse to write companies/<company>/skills/ (always the register behavior)")
|
|
593
|
+
.option("--no-surface", "Do not create a discovery wrapper (always the register behavior)")
|
|
594
|
+
.option("--no-sync", "Do not upload the skill file (always the register behavior)")
|
|
595
|
+
.action(async (slug, opts) => {
|
|
596
|
+
if (!SKILL_SLUG_PATTERN.test(slug)) {
|
|
597
|
+
throw localSkillError("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
|
|
598
|
+
}
|
|
599
|
+
const parentOpts = skill.opts();
|
|
600
|
+
const resolvedRoot = path.resolve(parentOpts.hqRoot ?? hqRoot);
|
|
601
|
+
const companySlug = resolveCompanySlug(parentOpts.company, resolvedRoot);
|
|
602
|
+
canonicalCompanySkillPath(resolvedRoot, companySlug, slug);
|
|
603
|
+
const sourcePath = resolveSkillMarkdownPath(opts.source, cwd());
|
|
604
|
+
if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) {
|
|
605
|
+
throw localSkillError(`No SKILL.md found at '${opts.source}'. Pass --source <file> or a directory that contains SKILL.md.`);
|
|
606
|
+
}
|
|
607
|
+
const stampedOutputPath = assertStagedOutputPath(resolvedRoot, path.resolve(cwd(), opts.stampedOutput));
|
|
608
|
+
const localContent = fs.readFileSync(sourcePath, "utf8");
|
|
609
|
+
const sourceHash = sha256Hex(localContent);
|
|
610
|
+
const token = await ensureToken();
|
|
611
|
+
const response = await apiFetch({
|
|
612
|
+
token,
|
|
613
|
+
path: `/v1/files/skills/company/${encodeURIComponent(companySlug)}/register`,
|
|
614
|
+
method: "POST",
|
|
615
|
+
body: {
|
|
616
|
+
path: skillVaultPath(slug),
|
|
617
|
+
content: localContent,
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
if (!response.ok) {
|
|
621
|
+
const body = (await response.json().catch(() => ({})));
|
|
622
|
+
throw skillApiError(response.status, body, {
|
|
623
|
+
machineIdentity: isMachineIdentity(),
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
const registered = assertRegisteredSkillResponse((await response.json()));
|
|
627
|
+
writeSkillFileAtomically(stampedOutputPath, registered.content);
|
|
628
|
+
const stampedHash = sha256Hex(registered.content);
|
|
629
|
+
console.log(chalk.green(`Skill registered (staged): ${registered.skillUid}`));
|
|
630
|
+
console.log(` Source: ${sourcePath}`);
|
|
631
|
+
console.log(` Source SHA-256: ${sourceHash}`);
|
|
632
|
+
console.log(` Stamped output: ${stampedOutputPath}`);
|
|
633
|
+
console.log(` Stamped SHA-256: ${stampedHash}`);
|
|
634
|
+
console.log(" Canonical write: not requested");
|
|
635
|
+
console.log(" Discovery: not requested");
|
|
636
|
+
console.log(" Sync: not requested");
|
|
637
|
+
});
|
|
638
|
+
skill
|
|
639
|
+
.command("promote <slug>")
|
|
640
|
+
.description("Copy independently-cleared stamped bytes into the canonical skill path after SHA-256 and skill_uid checks")
|
|
641
|
+
.requiredOption("--from <path>", "Independently-cleared stamped SKILL.md (or its directory)")
|
|
642
|
+
.requiredOption("--expected-sha256 <hex>", "SHA-256 of the exact stamped file bytes that were cleared")
|
|
643
|
+
.option("--reviewed-update", "Replace an existing canonical SKILL.md whose bytes differ from the stamped file")
|
|
644
|
+
.option("--no-sync", "Promote locally without uploading the canonical file")
|
|
645
|
+
.action(async (slug, opts) => {
|
|
646
|
+
if (!SKILL_SLUG_PATTERN.test(slug)) {
|
|
647
|
+
throw localSkillError("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
|
|
648
|
+
}
|
|
649
|
+
const parentOpts = skill.opts();
|
|
650
|
+
const resolvedRoot = path.resolve(parentOpts.hqRoot ?? hqRoot);
|
|
651
|
+
const companySlug = resolveCompanySlug(parentOpts.company, resolvedRoot);
|
|
652
|
+
const canonicalPath = canonicalCompanySkillPath(resolvedRoot, companySlug, slug);
|
|
653
|
+
const fromPath = resolveSkillMarkdownPath(opts.from, cwd());
|
|
654
|
+
if (!fs.existsSync(fromPath) || !fs.statSync(fromPath).isFile()) {
|
|
655
|
+
throw localSkillError(`No stamped SKILL.md found at '${opts.from}'. Pass --from <file> or a directory that contains SKILL.md.`);
|
|
656
|
+
}
|
|
657
|
+
const stampedBytes = fs.readFileSync(fromPath);
|
|
658
|
+
assertSha256HexMatch(sha256Hex(stampedBytes), opts.expectedSha256);
|
|
659
|
+
const stampedContent = stampedBytes.toString("utf8");
|
|
660
|
+
const skillUid = parseSkillUid(stampedContent);
|
|
661
|
+
if (!skillUid) {
|
|
662
|
+
throw localSkillError(`The stamped SKILL.md at '${fromPath}' has no registered skill_uid. Canonical skill was not written.`);
|
|
663
|
+
}
|
|
664
|
+
const token = await ensureToken();
|
|
665
|
+
const companyUid = await resolveCompanyUid(token, companySlug);
|
|
666
|
+
const detail = await apiFetch({
|
|
667
|
+
token,
|
|
668
|
+
path: `/v1/skills/${encodeURIComponent(companyUid)}/${encodeURIComponent(skillUid)}`,
|
|
669
|
+
method: "GET",
|
|
670
|
+
});
|
|
671
|
+
if (!detail.ok) {
|
|
672
|
+
const body = (await detail.json().catch(() => ({})));
|
|
673
|
+
throw skillApiError(detail.status, body, {
|
|
674
|
+
machineIdentity: isMachineIdentity(),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
const { skill: record } = (await detail.json());
|
|
678
|
+
if (typeof record?.skillUid === "string" &&
|
|
679
|
+
record.skillUid !== skillUid) {
|
|
680
|
+
throw localSkillError(`Stamped skill_uid ${skillUid} does not match the server registration. Canonical skill was not written.`);
|
|
681
|
+
}
|
|
682
|
+
const recordPath = skillRecordPath(record);
|
|
683
|
+
if (recordPath &&
|
|
684
|
+
normalizeLiveSkillVaultPath(recordPath) !== skillVaultPath(slug)) {
|
|
685
|
+
throw localSkillError(`Stamped skill_uid ${skillUid} is registered at '${recordPath}', not '${skillVaultPath(slug)}'. Canonical skill was not written.`);
|
|
686
|
+
}
|
|
687
|
+
if (fs.existsSync(canonicalPath) && !fs.statSync(canonicalPath).isFile()) {
|
|
688
|
+
throw localSkillError(`Expected a SKILL.md file at '${canonicalPath}'.`);
|
|
689
|
+
}
|
|
690
|
+
const existing = fs.existsSync(canonicalPath)
|
|
691
|
+
? fs.readFileSync(canonicalPath)
|
|
692
|
+
: null;
|
|
693
|
+
if (existing && !existing.equals(stampedBytes)) {
|
|
694
|
+
if (opts.reviewedUpdate !== true) {
|
|
695
|
+
throw localSkillError(`Canonical skill '${canonicalPath}' already exists and differs from the stamped file. Re-run with --reviewed-update after that exact change is cleared.`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (!existing || !existing.equals(stampedBytes)) {
|
|
699
|
+
writeSkillFileAtomically(canonicalPath, stampedBytes);
|
|
700
|
+
}
|
|
701
|
+
const legacyPrefix = readCompanyPrefix(resolvedRoot, companySlug);
|
|
702
|
+
let discoveryStatus = null;
|
|
703
|
+
try {
|
|
704
|
+
discoveryStatus = surfaceSkillFn({
|
|
705
|
+
hqRoot: resolvedRoot,
|
|
706
|
+
companySlug,
|
|
707
|
+
skillSlug: slug,
|
|
708
|
+
...(legacyPrefix ? { legacyPrefix } : {}),
|
|
709
|
+
}).status;
|
|
710
|
+
}
|
|
711
|
+
catch (err) {
|
|
712
|
+
console.warn(chalk.yellow(`⚠ Skill promoted, but local discovery failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
713
|
+
}
|
|
714
|
+
if (discoveryStatus !== null && discoveryStatus !== 0) {
|
|
715
|
+
console.warn(chalk.yellow(`⚠ Skill promoted, but local discovery exited ${discoveryStatus}. Run 'hq skill --company ${companySlug} create ${slug} --surface-only' to retry without another registration request.`));
|
|
716
|
+
}
|
|
717
|
+
if (opts.sync !== false) {
|
|
718
|
+
let syncResult;
|
|
719
|
+
try {
|
|
720
|
+
syncResult = await syncFile({
|
|
721
|
+
filePath: canonicalPath,
|
|
722
|
+
companySlug,
|
|
723
|
+
hqRoot: resolvedRoot,
|
|
724
|
+
token,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
catch (err) {
|
|
728
|
+
console.warn(chalk.yellow(`⚠ Skill ${skillUid} is stamped locally at '${canonicalPath}', but sync failed.`));
|
|
729
|
+
if (err && typeof err === "object") {
|
|
730
|
+
const diagnostics = vaultAccessDeniedDiagnostics(err, {
|
|
731
|
+
companySlug,
|
|
732
|
+
objectKey: skillVaultPath(slug),
|
|
733
|
+
});
|
|
734
|
+
if (diagnostics)
|
|
735
|
+
stampVaultAccessDenied(err, diagnostics);
|
|
736
|
+
}
|
|
737
|
+
throw err;
|
|
738
|
+
}
|
|
739
|
+
if (syncResult.aborted) {
|
|
740
|
+
throw localSkillError(`Skill ${skillUid} is stamped locally at '${canonicalPath}', but sync aborted because the remote file conflicts.`);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
console.log(chalk.green(`Skill promoted: ${skillUid}`));
|
|
744
|
+
console.log(` File: ${canonicalPath}`);
|
|
745
|
+
console.log(` SHA-256: ${sha256Hex(stampedBytes)}`);
|
|
746
|
+
console.log(` Discovery: ${discoveryStatus === 0 ? "ready" : "needs attention"}`);
|
|
747
|
+
console.log(` Sync: ${opts.sync === false ? "not requested" : "complete"}`);
|
|
748
|
+
});
|
|
514
749
|
skill
|
|
515
750
|
.command("propose <target>")
|
|
516
751
|
.description("Post a comment-only improvement for a skill")
|
|
@@ -83,6 +83,28 @@ export declare function recordPlanLimitStatus(body: unknown, opts?: {
|
|
|
83
83
|
* never claims the workspace has stopped working.
|
|
84
84
|
*/
|
|
85
85
|
export declare function buildOverLine(overEntries: Array<[string, PlanLimitEntry]>, upgradeUrl: string): string;
|
|
86
|
+
/**
|
|
87
|
+
* My Telemetry is an Individual-plan feature on a personal scope (owner
|
|
88
|
+
* decision 9 / us-044-individual-plan-design.md §11). When hq-pro refuses it,
|
|
89
|
+
* the CLI prints this notice INSTEAD of telemetry output.
|
|
90
|
+
*
|
|
91
|
+
* It lives beside the nag copy so plan wording stays in one file, but it is
|
|
92
|
+
* NOT part of the nag model: no persistence, no throttle, no episode state. It
|
|
93
|
+
* is rendered once, on the spot, by whoever received the refusal. The refusal
|
|
94
|
+
* is a plan fact, so it neither counts toward the nag bands nor is silenced by
|
|
95
|
+
* the nag cadence.
|
|
96
|
+
*
|
|
97
|
+
* Copy names the plan and its price only — no Outposts, no bot pricing (owner
|
|
98
|
+
* decisions 10 and 11).
|
|
99
|
+
*/
|
|
100
|
+
export declare const INDIVIDUAL_PLAN_NAME = "Individual";
|
|
101
|
+
export declare const INDIVIDUAL_PRICE_LABEL = "$50/mo";
|
|
102
|
+
/**
|
|
103
|
+
* The notice a personal feature refusal prints. `upgradeUrl` is the server's
|
|
104
|
+
* own checkout target; an older hq-pro sends none, and the line then names the
|
|
105
|
+
* plan without a link rather than inventing one.
|
|
106
|
+
*/
|
|
107
|
+
export declare function renderPersonalPlanFeatureNotice(feature: string, upgradeUrl?: string | null): string;
|
|
86
108
|
/**
|
|
87
109
|
* One line per crossing.
|
|
88
110
|
*
|
|
@@ -326,6 +326,42 @@ export function buildOverLine(overEntries, upgradeUrl) {
|
|
|
326
326
|
return (`⚠ HQ Starter: ${facts}. New files and new secrets are paused. ` +
|
|
327
327
|
`Upgrade: ${upgradeUrl}`);
|
|
328
328
|
}
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Individual-plan feature notice (US-054)
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
/**
|
|
333
|
+
* My Telemetry is an Individual-plan feature on a personal scope (owner
|
|
334
|
+
* decision 9 / us-044-individual-plan-design.md §11). When hq-pro refuses it,
|
|
335
|
+
* the CLI prints this notice INSTEAD of telemetry output.
|
|
336
|
+
*
|
|
337
|
+
* It lives beside the nag copy so plan wording stays in one file, but it is
|
|
338
|
+
* NOT part of the nag model: no persistence, no throttle, no episode state. It
|
|
339
|
+
* is rendered once, on the spot, by whoever received the refusal. The refusal
|
|
340
|
+
* is a plan fact, so it neither counts toward the nag bands nor is silenced by
|
|
341
|
+
* the nag cadence.
|
|
342
|
+
*
|
|
343
|
+
* Copy names the plan and its price only — no Outposts, no bot pricing (owner
|
|
344
|
+
* decisions 10 and 11).
|
|
345
|
+
*/
|
|
346
|
+
export const INDIVIDUAL_PLAN_NAME = "Individual";
|
|
347
|
+
export const INDIVIDUAL_PRICE_LABEL = "$50/mo";
|
|
348
|
+
/** Human names for the features a personal scope can be refused. */
|
|
349
|
+
const PERSONAL_FEATURE_LABELS = {
|
|
350
|
+
telemetry: "My Telemetry",
|
|
351
|
+
};
|
|
352
|
+
/**
|
|
353
|
+
* The notice a personal feature refusal prints. `upgradeUrl` is the server's
|
|
354
|
+
* own checkout target; an older hq-pro sends none, and the line then names the
|
|
355
|
+
* plan without a link rather than inventing one.
|
|
356
|
+
*/
|
|
357
|
+
export function renderPersonalPlanFeatureNotice(feature, upgradeUrl) {
|
|
358
|
+
const label = PERSONAL_FEATURE_LABELS[feature] ?? feature;
|
|
359
|
+
const headline = `${label} is included with the HQ ${INDIVIDUAL_PLAN_NAME} plan (${INDIVIDUAL_PRICE_LABEL}).`;
|
|
360
|
+
const action = typeof upgradeUrl === "string" && upgradeUrl.trim().length > 0
|
|
361
|
+
? `Upgrade: ${upgradeUrl.trim()}`
|
|
362
|
+
: "Upgrade in your HQ billing settings.";
|
|
363
|
+
return `${headline} Your personal account is on the free tier. ${action}`;
|
|
364
|
+
}
|
|
329
365
|
/** `"{resource}:{band}"` — the persisted key, matching the server's own. */
|
|
330
366
|
function crossingKey(crossing) {
|
|
331
367
|
return `${crossing.resource}:${crossing.band}`;
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
export type PlanGateCode = "PLAN_LIMIT_EXCEEDED" | "PLAN_REQUIRED"
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* US-054: a PERSONAL scope asked for a feature the paid Individual plan
|
|
4
|
+
* includes and the free tier does not. A different plan, a different price
|
|
5
|
+
* and a different remedy from the company codes above, so it is its own
|
|
6
|
+
* code rather than a reuse of `PLAN_REQUIRED` with company copy.
|
|
5
7
|
*/
|
|
6
|
-
|
|
8
|
+
| "PERSONAL_PLAN_REQUIRED";
|
|
7
9
|
export interface PlanGateDetails {
|
|
8
10
|
resource?: string;
|
|
11
|
+
/** The refused feature, on a `PERSONAL_PLAN_REQUIRED` denial. */
|
|
12
|
+
feature?: string;
|
|
9
13
|
used?: number;
|
|
10
14
|
limit?: number;
|
|
11
15
|
upgradeUrl?: string;
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deliberate subscription denial returned by hq-pro. Keeping its structured
|
|
3
|
+
* fields on a typed error lets the CLI boundary render one safe, consistent
|
|
4
|
+
* message instead of every command parsing and printing a server response.
|
|
5
|
+
*/
|
|
6
|
+
import { renderPersonalPlanFeatureNotice } from "../lib/plan-limit-nag.js";
|
|
1
7
|
const TEAM_UPGRADE_HINT = "Run `hq billing upgrade` to move to HQ Workforce.";
|
|
2
8
|
export class PlanGateError extends Error {
|
|
3
9
|
code;
|
|
@@ -6,7 +12,11 @@ export class PlanGateError extends Error {
|
|
|
6
12
|
// Commands with their own expected-error boundary commonly print
|
|
7
13
|
// `err.message`. Keeping the friendly copy here means those boundaries
|
|
8
14
|
// retain the same plan-gate voice as main.ts without per-command handling.
|
|
9
|
-
|
|
15
|
+
// The HQ Workforce hint is company advice; a personal denial ends at its
|
|
16
|
+
// own notice, which already names the Individual plan and its checkout.
|
|
17
|
+
super(code === "PERSONAL_PLAN_REQUIRED"
|
|
18
|
+
? formatPlanGateDetails(code, details)
|
|
19
|
+
: [formatPlanGateDetails(code, details), TEAM_UPGRADE_HINT].join("\n"));
|
|
10
20
|
this.code = code;
|
|
11
21
|
this.details = details;
|
|
12
22
|
this.name = "PlanGateError";
|
|
@@ -31,6 +41,12 @@ function formatPlanGateDetails(code, details) {
|
|
|
31
41
|
existingResourcesNote,
|
|
32
42
|
].join("\n");
|
|
33
43
|
}
|
|
44
|
+
// A personal scope is not a workspace: it has no members to trim, no $500
|
|
45
|
+
// tier to buy, and nothing of its own is paused. One line, the plan's own
|
|
46
|
+
// copy, and no company remedy.
|
|
47
|
+
if (code === "PERSONAL_PLAN_REQUIRED") {
|
|
48
|
+
return renderPersonalPlanFeatureNotice(details.feature ?? "this feature", details.upgradeUrl ?? null);
|
|
49
|
+
}
|
|
34
50
|
const upgrade = typeof details.upgradeUrl === "string"
|
|
35
51
|
? `Upgrade to HQ Workforce ($500/mo) to remove limits: ${details.upgradeUrl}`
|
|
36
52
|
: "Upgrade to HQ Workforce ($500/mo) to remove limits.";
|
|
@@ -41,6 +57,9 @@ function formatPlanGateDetails(code, details) {
|
|
|
41
57
|
].join("\n");
|
|
42
58
|
}
|
|
43
59
|
export function formatPlanGateError(err) {
|
|
60
|
+
if (err.code === "PERSONAL_PLAN_REQUIRED") {
|
|
61
|
+
return formatPlanGateDetails(err.code, err.details);
|
|
62
|
+
}
|
|
44
63
|
return [
|
|
45
64
|
formatPlanGateDetails(err.code, err.details),
|
|
46
65
|
TEAM_UPGRADE_HINT,
|
|
@@ -80,6 +99,22 @@ export function planGateErrorFromPayload(status, body, companyUid) {
|
|
|
80
99
|
if (status !== 402 || !body || typeof body !== "object")
|
|
81
100
|
return null;
|
|
82
101
|
const payload = body;
|
|
102
|
+
// US-054: the personal feature denial. hq-pro identifies it with
|
|
103
|
+
// `error: "plan_feature_unavailable"` plus `scope: "personal"`; both the
|
|
104
|
+
// `scope` and `requiredPlan` fields are OPTIONAL on the wire, so an hq-pro
|
|
105
|
+
// that predates US-054 sends a company-shaped denial and keeps falling
|
|
106
|
+
// through to the company path below (defensive consumer).
|
|
107
|
+
if (payload.error === "plan_feature_unavailable" &&
|
|
108
|
+
payload.scope === "personal") {
|
|
109
|
+
return new PlanGateError("PERSONAL_PLAN_REQUIRED", {
|
|
110
|
+
...(typeof payload.feature === "string"
|
|
111
|
+
? { feature: payload.feature }
|
|
112
|
+
: {}),
|
|
113
|
+
...(typeof payload.upgradeUrl === "string"
|
|
114
|
+
? { upgradeUrl: payload.upgradeUrl }
|
|
115
|
+
: {}),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
83
118
|
if (payload.code !== "PLAN_LIMIT_EXCEEDED" && payload.code !== "PLAN_REQUIRED") {
|
|
84
119
|
return null;
|
|
85
120
|
}
|
|
@@ -59,6 +59,7 @@ export declare function vaultApiFetchPublic(opts: {
|
|
|
59
59
|
query?: Record<string, string>;
|
|
60
60
|
}): Promise<Response>;
|
|
61
61
|
export declare function looksLikeCompanyUid(ref: string): boolean;
|
|
62
|
+
export declare function looksLikePersonVaultUid(ref: string): boolean;
|
|
62
63
|
export declare function getCompanyUid(token: string, companySlug: string | undefined): Promise<string>;
|
|
63
64
|
export declare function resolveCallerPersonUid(token: string, baseUrl?: string): Promise<string>;
|
|
64
65
|
export declare function getEntityUid(token: string, opts: {
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -285,6 +285,24 @@ const COMPANY_UID_PREFIX = 'cmp_';
|
|
|
285
285
|
export function looksLikeCompanyUid(ref) {
|
|
286
286
|
return ref.startsWith(COMPANY_UID_PREFIX);
|
|
287
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* A `--company` value beginning with `prs_` names a PERSON'S vault, not a
|
|
290
|
+
* company. It is the only way to reach a DM attachment: the file lives in the
|
|
291
|
+
* SENDER's personal vault under
|
|
292
|
+
* `chat/attachments/dm/<uidA>--<uidB>/…`, and the vault API authorizes either
|
|
293
|
+
* member of that pair to `get` it (hq-pro #3571). The recipient has no
|
|
294
|
+
* personal vault of their own (so `--personal` cannot work) and the key is not
|
|
295
|
+
* in any company bucket (so a slug 404s on the object), which left the read
|
|
296
|
+
* with no expressible scope at all.
|
|
297
|
+
*
|
|
298
|
+
* Routed straight through as the scope uid: person entities have no slug
|
|
299
|
+
* namespace, so there is nothing to resolve, and the server's own per-key gate
|
|
300
|
+
* — not the CLI — decides whether the caller may read it.
|
|
301
|
+
*/
|
|
302
|
+
const PERSON_VAULT_UID_PREFIX = 'prs_';
|
|
303
|
+
export function looksLikePersonVaultUid(ref) {
|
|
304
|
+
return ref.startsWith(PERSON_VAULT_UID_PREFIX);
|
|
305
|
+
}
|
|
288
306
|
// A 401 from ANY vault resolution call means the caller's HQ session is
|
|
289
307
|
// expired or missing — an expected auth state fixed by `hq login`, not a
|
|
290
308
|
// code defect. Raise a typed AuthError so the top-level handler prints an
|
|
@@ -351,6 +369,12 @@ async function resolveCompanyUid(token, ref) {
|
|
|
351
369
|
if (looksLikeCompanyUid(ref)) {
|
|
352
370
|
return resolveCompanyByUid(token, ref);
|
|
353
371
|
}
|
|
372
|
+
// A `prs_` ref names a person's vault (DM attachments). There is no slug to
|
|
373
|
+
// resolve and no company lookup that could succeed; pass it through as the
|
|
374
|
+
// scope and let the vault API's per-key authorization answer.
|
|
375
|
+
if (looksLikePersonVaultUid(ref)) {
|
|
376
|
+
return ref;
|
|
377
|
+
}
|
|
354
378
|
// PRIMARY PATH — caller-scoped slug resolution. Resolves the slug to the
|
|
355
379
|
// caller's OWN company (unique within their namespace by the invariant
|
|
356
380
|
// above), making a stranger's same-slug company invisible.
|