@indigoai-us/hq-cli 5.103.25 → 5.103.27
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 +11 -0
- package/dist/commands/dm.js +3 -7
- package/dist/commands/skill.d.ts +4 -6
- package/dist/commands/skill.js +57 -10
- package/dist/lib/company-skill-wrapper.d.ts +24 -0
- package/dist/lib/company-skill-wrapper.js +276 -0
- package/dist/lib/doctor/checks/integrations.d.ts +5 -1
- package/dist/lib/doctor/checks/integrations.js +173 -32
- package/dist/lib/search-index/index.d.ts +22 -0
- package/dist/lib/search-index/index.js +37 -0
- package/dist/main.js +24 -2
- package/dist/utils/qmd-llm-disabled-error.d.ts +10 -0
- package/dist/utils/qmd-llm-disabled-error.js +87 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.103.27] — 2026-08-28
|
|
6
|
+
|
|
7
|
+
## [5.103.26] — 2026-08-27
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Company skill creation and collaboration now use the shared `skills/`
|
|
12
|
+
directory end to end: new skills are discovered and registered with stable
|
|
13
|
+
IDs, `hq skill propose` posts comment-only improvements, and local Claude,
|
|
14
|
+
Cursor, and Codex skill links stay scoped to the active company.
|
|
15
|
+
|
|
5
16
|
## [5.103.25] — 2026-08-27
|
|
6
17
|
|
|
7
18
|
### Fixed
|
package/dist/commands/dm.js
CHANGED
|
@@ -272,15 +272,11 @@ async function runGroupSend(recipients, message) {
|
|
|
272
272
|
for (const r of recipients) {
|
|
273
273
|
const rc = detectRecipient(r);
|
|
274
274
|
if (!rc) {
|
|
275
|
-
console.error(chalk.red(`Invalid recipient '${r}': each must be an email address
|
|
276
|
-
process.exit(1);
|
|
277
|
-
}
|
|
278
|
-
// Group DMs are channels — agents don't participate in channels (their
|
|
279
|
-
// DM surface is 1:1 via the durable box inbox). DM an agent directly.
|
|
280
|
-
if (rc.toPersonUid?.startsWith("agt_")) {
|
|
281
|
-
console.error(chalk.red(`Agents can't join group DMs yet — DM '${r}' directly: hq dm ${r} "<message>".`));
|
|
275
|
+
console.error(chalk.red(`Invalid recipient '${r}': each must be an email address, a personUid (prs_…), or an agentUid (agt_…).`));
|
|
282
276
|
process.exit(1);
|
|
283
277
|
}
|
|
278
|
+
// People (prs_) and agents (agt_) are both valid group participants —
|
|
279
|
+
// the server (handleCreateGroupDm) accepts agt_* uids like any other.
|
|
284
280
|
participants.push(rc.toEmail ?? rc.toPersonUid);
|
|
285
281
|
}
|
|
286
282
|
if (participants.length < 2) {
|
package/dist/commands/skill.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Company skill creation and comment-only improvements.
|
|
3
3
|
*
|
|
4
4
|
* `hq skill create <slug>` registers a canonical company skill, stamps its
|
|
5
|
-
* immutable UID,
|
|
5
|
+
* immutable UID, surfaces its generated runtime wrapper, and syncs it.
|
|
6
6
|
* `hq skill propose <uid|path> --message "…"` posts a whole-skill comment to
|
|
7
7
|
* the same improvement thread shown in HQ Console. It never uploads a modified
|
|
8
8
|
* SKILL.md and cannot overwrite live content. Structured suggest/list/review
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { Command } from "commander";
|
|
12
12
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
13
13
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
14
|
+
import { surfaceCompanySkill } from "../lib/company-skill-wrapper.js";
|
|
14
15
|
export declare const SKILL_UID_PATTERN: RegExp;
|
|
15
16
|
export declare const SKILL_SLUG_PATTERN: RegExp;
|
|
16
17
|
interface SkillSyncInput {
|
|
@@ -27,6 +28,7 @@ interface SkillSyncResult {
|
|
|
27
28
|
export declare function parseSkillUid(markdown: string): string | undefined;
|
|
28
29
|
export declare function readActiveCompanySlug(hqRoot: string): string | undefined;
|
|
29
30
|
export declare function resolveCompanySlug(flag: string | undefined, hqRoot?: string): string;
|
|
31
|
+
export declare function readCompanyPrefix(hqRoot: string, companySlug: string): string | undefined;
|
|
30
32
|
/** Resolve a UID directly, or read the immutable UID from a local SKILL.md. */
|
|
31
33
|
export declare function resolveSkillUid(target: string, cwd: string): string;
|
|
32
34
|
export declare function canonicalCompanySkillPath(hqRoot: string, companySlug: string, skillSlug: string): string;
|
|
@@ -44,11 +46,7 @@ interface SkillCommandDeps {
|
|
|
44
46
|
cwd?: () => string;
|
|
45
47
|
hqRoot?: string;
|
|
46
48
|
syncFile?: (input: SkillSyncInput) => Promise<SkillSyncResult>;
|
|
47
|
-
|
|
48
|
-
repoRoot: string;
|
|
49
|
-
}) => {
|
|
50
|
-
status: number | null;
|
|
51
|
-
};
|
|
49
|
+
surfaceSkillFn?: typeof surfaceCompanySkill;
|
|
52
50
|
}
|
|
53
51
|
export declare function registerSkillCommand(program: Command, deps?: SkillCommandDeps): Command;
|
|
54
52
|
export {};
|
package/dist/commands/skill.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Company skill creation and comment-only improvements.
|
|
3
3
|
*
|
|
4
4
|
* `hq skill create <slug>` registers a canonical company skill, stamps its
|
|
5
|
-
* immutable UID,
|
|
5
|
+
* immutable UID, surfaces its generated runtime wrapper, and syncs it.
|
|
6
6
|
* `hq skill propose <uid|path> --message "…"` posts a whole-skill comment to
|
|
7
7
|
* the same improvement thread shown in HQ Console. It never uploads a modified
|
|
8
8
|
* SKILL.md and cannot overwrite live content. Structured suggest/list/review
|
|
@@ -12,9 +12,10 @@ import * as fs from "node:fs";
|
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import chalk from "chalk";
|
|
14
14
|
import yaml from "js-yaml";
|
|
15
|
-
import {
|
|
15
|
+
import { share } from "@indigoai-us/hq-cloud";
|
|
16
16
|
import { ensureCognitoToken, DEFAULT_HQ_ROOT, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
17
17
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
18
|
+
import { surfaceCompanySkill } from "../lib/company-skill-wrapper.js";
|
|
18
19
|
export const SKILL_UID_PATTERN = /^skl_[A-Za-z0-9]+$/;
|
|
19
20
|
export const SKILL_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
20
21
|
const COMPANY_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
@@ -69,6 +70,31 @@ export function resolveCompanySlug(flag, hqRoot = DEFAULT_HQ_ROOT) {
|
|
|
69
70
|
}
|
|
70
71
|
return slug;
|
|
71
72
|
}
|
|
73
|
+
export function readCompanyPrefix(hqRoot, companySlug) {
|
|
74
|
+
const manifestPath = path.join(hqRoot, "companies", "manifest.yaml");
|
|
75
|
+
if (!fs.existsSync(manifestPath))
|
|
76
|
+
return undefined;
|
|
77
|
+
try {
|
|
78
|
+
const parsed = yaml.load(fs.readFileSync(manifestPath, "utf8"));
|
|
79
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
80
|
+
return undefined;
|
|
81
|
+
const companies = parsed.companies;
|
|
82
|
+
if (!companies || typeof companies !== "object" || Array.isArray(companies)) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const company = companies[companySlug];
|
|
86
|
+
if (!company || typeof company !== "object" || Array.isArray(company)) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const prefix = company.prefix;
|
|
90
|
+
return typeof prefix === "string" && /^[a-z0-9][a-z0-9-]*$/.test(prefix)
|
|
91
|
+
? prefix
|
|
92
|
+
: undefined;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
72
98
|
/** Resolve a UID directly, or read the immutable UID from a local SKILL.md. */
|
|
73
99
|
export function resolveSkillUid(target, cwd) {
|
|
74
100
|
if (SKILL_UID_PATTERN.test(target))
|
|
@@ -159,7 +185,7 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
159
185
|
const cwd = deps.cwd ?? process.cwd;
|
|
160
186
|
const hqRoot = deps.hqRoot ?? DEFAULT_HQ_ROOT;
|
|
161
187
|
const syncFile = deps.syncFile ?? defaultSyncFile;
|
|
162
|
-
const
|
|
188
|
+
const surfaceSkillFn = deps.surfaceSkillFn ?? surfaceCompanySkill;
|
|
163
189
|
const skill = program
|
|
164
190
|
.command("skill")
|
|
165
191
|
.description("Create company skills and discuss improvements")
|
|
@@ -167,10 +193,11 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
167
193
|
.option("--hq-root <path>", "Local HQ root", hqRoot);
|
|
168
194
|
skill
|
|
169
195
|
.command("create <slug>")
|
|
170
|
-
.description("Register, stamp,
|
|
196
|
+
.description("Register, stamp, surface, and sync a company skill")
|
|
171
197
|
.option("--name <name>", "Display name when creating a new template")
|
|
172
198
|
.option("--description <text>", "Description when creating a new template")
|
|
173
199
|
.option("--no-sync", "Register locally without uploading the stamped file")
|
|
200
|
+
.option("--surface-only", "Refresh local skill discovery without registration or sync")
|
|
174
201
|
.action(async (slug, opts) => {
|
|
175
202
|
if (!SKILL_SLUG_PATTERN.test(slug)) {
|
|
176
203
|
throw new Error("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
|
|
@@ -179,9 +206,24 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
179
206
|
const resolvedRoot = path.resolve(parentOpts.hqRoot ?? hqRoot);
|
|
180
207
|
const companySlug = resolveCompanySlug(parentOpts.company, resolvedRoot);
|
|
181
208
|
const filePath = canonicalCompanySkillPath(resolvedRoot, companySlug, slug);
|
|
209
|
+
const legacyPrefix = readCompanyPrefix(resolvedRoot, companySlug);
|
|
182
210
|
if (fs.existsSync(filePath) && !fs.statSync(filePath).isFile()) {
|
|
183
211
|
throw new Error(`Expected a SKILL.md file at '${filePath}'.`);
|
|
184
212
|
}
|
|
213
|
+
if (opts.surfaceOnly === true) {
|
|
214
|
+
// Discovery wrappers are execution surfaces. Never create one for an
|
|
215
|
+
// unregistered file, because that would bypass immutable identity and
|
|
216
|
+
// the FILE_ACL-backed registration flow.
|
|
217
|
+
resolveSkillUid(filePath, resolvedRoot);
|
|
218
|
+
const surfaced = surfaceSkillFn({
|
|
219
|
+
hqRoot: resolvedRoot,
|
|
220
|
+
companySlug,
|
|
221
|
+
skillSlug: slug,
|
|
222
|
+
...(legacyPrefix ? { legacyPrefix } : {}),
|
|
223
|
+
});
|
|
224
|
+
console.log(chalk.green(`Local discovery ready: ${surfaced.wrapperPath}`));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
185
227
|
const localContent = fs.existsSync(filePath)
|
|
186
228
|
? fs.readFileSync(filePath, "utf8")
|
|
187
229
|
: makeSkillTemplate({
|
|
@@ -213,15 +255,20 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
213
255
|
throw new Error("The server returned an invalid skill registration response; the local file was not changed.");
|
|
214
256
|
}
|
|
215
257
|
writeSkillFileAtomically(filePath, registered.content);
|
|
216
|
-
let
|
|
258
|
+
let discoveryStatus = null;
|
|
217
259
|
try {
|
|
218
|
-
|
|
260
|
+
discoveryStatus = surfaceSkillFn({
|
|
261
|
+
hqRoot: resolvedRoot,
|
|
262
|
+
companySlug,
|
|
263
|
+
skillSlug: slug,
|
|
264
|
+
...(legacyPrefix ? { legacyPrefix } : {}),
|
|
265
|
+
}).status;
|
|
219
266
|
}
|
|
220
267
|
catch (err) {
|
|
221
|
-
console.warn(chalk.yellow(`⚠ Skill registered, but
|
|
268
|
+
console.warn(chalk.yellow(`⚠ Skill registered, but local discovery failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
222
269
|
}
|
|
223
|
-
if (
|
|
224
|
-
console.warn(chalk.yellow(`⚠ Skill registered, but
|
|
270
|
+
if (discoveryStatus !== null && discoveryStatus !== 0) {
|
|
271
|
+
console.warn(chalk.yellow(`⚠ Skill registered, but local discovery exited ${discoveryStatus}. Run 'hq skill --company ${companySlug} create ${slug} --surface-only' to retry without another registration request.`));
|
|
225
272
|
}
|
|
226
273
|
if (opts.sync !== false) {
|
|
227
274
|
let syncResult;
|
|
@@ -242,7 +289,7 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
242
289
|
}
|
|
243
290
|
console.log(chalk.green(`Skill ready: ${registered.skillUid}`));
|
|
244
291
|
console.log(` File: ${filePath}`);
|
|
245
|
-
console.log(` Discovery: ${
|
|
292
|
+
console.log(` Discovery: ${discoveryStatus === 0 ? "ready" : "needs attention"}`);
|
|
246
293
|
console.log(registered.accessPolicy === "open"
|
|
247
294
|
? ` Access: Open — every active ${companySlug} member can edit`
|
|
248
295
|
: " Access: preserved existing policy");
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
export interface SurfaceCompanySkillInput {
|
|
3
|
+
hqRoot: string;
|
|
4
|
+
companySlug: string;
|
|
5
|
+
skillSlug: string;
|
|
6
|
+
/** Prefix used by pre-namespaced generated wrappers (for one-time cleanup). */
|
|
7
|
+
legacyPrefix?: string;
|
|
8
|
+
/** Test seam for the Win32 filename adapter. */
|
|
9
|
+
win32?: boolean;
|
|
10
|
+
/** Test seam for replacement rollback failures. */
|
|
11
|
+
renameEntry?: typeof fs.renameSync;
|
|
12
|
+
/** Test seam for environments that deny local symlink creation. */
|
|
13
|
+
symlinkEntry?: typeof fs.symlinkSync;
|
|
14
|
+
}
|
|
15
|
+
export interface SurfaceCompanySkillResult {
|
|
16
|
+
status: 0;
|
|
17
|
+
wrapperPath: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Surface one canonical company skill to Claude/Codex without running HQ's
|
|
21
|
+
* global reindex, migrations, worker generation, or unrelated cleanup.
|
|
22
|
+
*/
|
|
23
|
+
export declare function surfaceCompanySkill(input: SurfaceCompanySkillInput): SurfaceCompanySkillResult;
|
|
24
|
+
//# sourceMappingURL=company-skill-wrapper.d.ts.map
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
const WIN32_RESERVED_CHARS = /[<>:"/\\|?*]/;
|
|
4
|
+
const WIN32_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
5
|
+
const LOCAL_SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
6
|
+
const WRAPPER_MARKER_DIR = ".hq-company-skill-wrappers";
|
|
7
|
+
function encodeLocalSegment(segment, win32) {
|
|
8
|
+
if (!win32)
|
|
9
|
+
return segment;
|
|
10
|
+
let encoded = "";
|
|
11
|
+
for (let index = 0; index < segment.length; index += 1) {
|
|
12
|
+
const char = segment[index];
|
|
13
|
+
const trailingDotOrSpace = index === segment.length - 1 && (char === "." || char === " ");
|
|
14
|
+
const dotSegment = (segment === "." || segment === "..") && index === 0;
|
|
15
|
+
const deviceName = WIN32_DEVICE_NAME.test(segment) && index === 0;
|
|
16
|
+
if (char === "%" ||
|
|
17
|
+
char.charCodeAt(0) <= 0x1f ||
|
|
18
|
+
WIN32_RESERVED_CHARS.test(char) ||
|
|
19
|
+
trailingDotOrSpace ||
|
|
20
|
+
dotSegment ||
|
|
21
|
+
deviceName) {
|
|
22
|
+
encoded += `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
encoded += char;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return encoded;
|
|
29
|
+
}
|
|
30
|
+
function lstat(pathname) {
|
|
31
|
+
try {
|
|
32
|
+
return fs.lstatSync(pathname);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function isWithin(parent, candidate) {
|
|
39
|
+
const relative = path.relative(parent, candidate);
|
|
40
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
41
|
+
}
|
|
42
|
+
function sameFile(left, right) {
|
|
43
|
+
try {
|
|
44
|
+
const a = fs.statSync(left);
|
|
45
|
+
const b = fs.statSync(right);
|
|
46
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function symlinkTarget(linkPath) {
|
|
53
|
+
try {
|
|
54
|
+
if (!fs.lstatSync(linkPath).isSymbolicLink())
|
|
55
|
+
return undefined;
|
|
56
|
+
return path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function generatedMarkerPath(wrapperPath) {
|
|
63
|
+
return path.join(path.dirname(wrapperPath), WRAPPER_MARKER_DIR, `${path.basename(wrapperPath)}.json`);
|
|
64
|
+
}
|
|
65
|
+
function hasBoundGeneratedMarker(wrapperPath, companySlug) {
|
|
66
|
+
try {
|
|
67
|
+
const marker = JSON.parse(fs.readFileSync(generatedMarkerPath(wrapperPath), "utf8"));
|
|
68
|
+
const wrapper = fs.lstatSync(wrapperPath);
|
|
69
|
+
return (marker.version === 2 &&
|
|
70
|
+
marker.companySlug === companySlug &&
|
|
71
|
+
marker.wrapperDev === wrapper.dev &&
|
|
72
|
+
marker.wrapperIno === wrapper.ino &&
|
|
73
|
+
marker.wrapperBirthtimeMs === wrapper.birthtimeMs);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function writeGeneratedMarker(wrapperPath, companySlug, skillSlug) {
|
|
80
|
+
const markerPath = generatedMarkerPath(wrapperPath);
|
|
81
|
+
try {
|
|
82
|
+
const wrapper = fs.lstatSync(wrapperPath);
|
|
83
|
+
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
|
84
|
+
fs.writeFileSync(markerPath, `${JSON.stringify({
|
|
85
|
+
version: 2,
|
|
86
|
+
companySlug,
|
|
87
|
+
skillSlug,
|
|
88
|
+
wrapperDev: wrapper.dev,
|
|
89
|
+
wrapperIno: wrapper.ino,
|
|
90
|
+
wrapperBirthtimeMs: wrapper.birthtimeMs,
|
|
91
|
+
})}\n`, "utf8");
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Every generated entry is also a live symlink into the canonical company
|
|
95
|
+
// skill, so wrapper ownership remains recoverable if this hint cannot land.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function removeGeneratedMarker(wrapperPath) {
|
|
99
|
+
try {
|
|
100
|
+
fs.rmSync(generatedMarkerPath(wrapperPath), { force: true });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// The wrapper itself is already gone; an orphaned hint is harmless and is
|
|
104
|
+
// never sufficient to surface content on its own.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function isGeneratedNamespacedWrapper(wrapperPath, hqRoot, companySlug) {
|
|
108
|
+
const companySkillsRoot = path.join(hqRoot, "companies", companySlug, "skills");
|
|
109
|
+
const wrapperTarget = symlinkTarget(wrapperPath);
|
|
110
|
+
if (wrapperTarget)
|
|
111
|
+
return isWithin(companySkillsRoot, wrapperTarget);
|
|
112
|
+
const wrapper = lstat(wrapperPath);
|
|
113
|
+
if (!wrapper?.isDirectory())
|
|
114
|
+
return false;
|
|
115
|
+
const entries = fs.readdirSync(wrapperPath).filter((entry) => !entry.startsWith("."));
|
|
116
|
+
const containsOnlyLiveBridges = (entries.length > 0 &&
|
|
117
|
+
entries.every((entry) => {
|
|
118
|
+
const target = symlinkTarget(path.join(wrapperPath, entry));
|
|
119
|
+
return target !== undefined && isWithin(companySkillsRoot, target);
|
|
120
|
+
}));
|
|
121
|
+
return containsOnlyLiveBridges || hasBoundGeneratedMarker(wrapperPath, companySlug);
|
|
122
|
+
}
|
|
123
|
+
function isGeneratedLegacyEntry(legacyPath, sourceDir, skillFile) {
|
|
124
|
+
const target = symlinkTarget(legacyPath);
|
|
125
|
+
if (target)
|
|
126
|
+
return target === sourceDir || target === skillFile;
|
|
127
|
+
const entry = lstat(legacyPath);
|
|
128
|
+
if (!entry)
|
|
129
|
+
return false;
|
|
130
|
+
if (entry.isFile())
|
|
131
|
+
return sameFile(legacyPath, skillFile);
|
|
132
|
+
if (!entry.isDirectory())
|
|
133
|
+
return false;
|
|
134
|
+
const legacySkill = path.join(legacyPath, "SKILL.md");
|
|
135
|
+
const skillTarget = symlinkTarget(legacySkill);
|
|
136
|
+
return skillTarget === skillFile || sameFile(legacySkill, skillFile);
|
|
137
|
+
}
|
|
138
|
+
function pruneStaleNamespacedWrappers(runtimeRoot, currentWrapperName, hqRoot, companySlug, win32) {
|
|
139
|
+
const namespacePrefix = encodeLocalSegment(`${companySlug}:`, win32);
|
|
140
|
+
for (const name of fs.readdirSync(runtimeRoot)) {
|
|
141
|
+
if (name === currentWrapperName || !name.startsWith(namespacePrefix))
|
|
142
|
+
continue;
|
|
143
|
+
const candidate = path.join(runtimeRoot, name);
|
|
144
|
+
const skillSlug = name.slice(namespacePrefix.length);
|
|
145
|
+
const canonicalSkill = path.join(hqRoot, "companies", companySlug, "skills", skillSlug, "SKILL.md");
|
|
146
|
+
if (LOCAL_SLUG.test(skillSlug) &&
|
|
147
|
+
!fs.existsSync(canonicalSkill) &&
|
|
148
|
+
isGeneratedNamespacedWrapper(candidate, hqRoot, companySlug)) {
|
|
149
|
+
fs.rmSync(candidate, { recursive: true, force: false });
|
|
150
|
+
removeGeneratedMarker(candidate);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function bridgeEntry(sourcePath, wrapperPath, target, type, symlinkEntry) {
|
|
155
|
+
try {
|
|
156
|
+
symlinkEntry(target, wrapperPath, type);
|
|
157
|
+
}
|
|
158
|
+
catch (symlinkError) {
|
|
159
|
+
const reason = symlinkError instanceof Error
|
|
160
|
+
? symlinkError.message
|
|
161
|
+
: (JSON.stringify(symlinkError) ?? "Unknown symlink error");
|
|
162
|
+
throw new Error(`Could not surface '${sourcePath}' as a live skill link: ${reason}. ` +
|
|
163
|
+
"Enable local symlink support (Windows Developer Mode or an elevated shell) and retry.");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Surface one canonical company skill to Claude/Codex without running HQ's
|
|
168
|
+
* global reindex, migrations, worker generation, or unrelated cleanup.
|
|
169
|
+
*/
|
|
170
|
+
export function surfaceCompanySkill(input) {
|
|
171
|
+
if (!LOCAL_SLUG.test(input.companySlug)) {
|
|
172
|
+
throw new Error("Company slug must contain only lowercase letters, numbers, and hyphens.");
|
|
173
|
+
}
|
|
174
|
+
if (!LOCAL_SLUG.test(input.skillSlug)) {
|
|
175
|
+
throw new Error("Skill slug must contain only lowercase letters, numbers, and hyphens.");
|
|
176
|
+
}
|
|
177
|
+
if (input.legacyPrefix !== undefined && !LOCAL_SLUG.test(input.legacyPrefix)) {
|
|
178
|
+
throw new Error("Legacy prefix must contain only lowercase letters, numbers, and hyphens.");
|
|
179
|
+
}
|
|
180
|
+
const sourceDir = path.join(input.hqRoot, "companies", input.companySlug, "skills", input.skillSlug);
|
|
181
|
+
const skillFile = path.join(sourceDir, "SKILL.md");
|
|
182
|
+
if (!fs.existsSync(skillFile) || !fs.statSync(skillFile).isFile()) {
|
|
183
|
+
throw new Error(`The canonical SKILL.md does not exist at '${skillFile}'.`);
|
|
184
|
+
}
|
|
185
|
+
const wrapperName = encodeLocalSegment(`${input.companySlug}:${input.skillSlug}`, input.win32 ?? process.platform === "win32");
|
|
186
|
+
const win32 = input.win32 ?? process.platform === "win32";
|
|
187
|
+
const wrapperPath = path.join(input.hqRoot, ".claude", "skills", wrapperName);
|
|
188
|
+
const existingWrapper = lstat(wrapperPath);
|
|
189
|
+
if (existingWrapper &&
|
|
190
|
+
!existingWrapper.isSymbolicLink() &&
|
|
191
|
+
!existingWrapper.isDirectory()) {
|
|
192
|
+
throw new Error(`Cannot surface the skill because '${wrapperPath}' is not a directory.`);
|
|
193
|
+
}
|
|
194
|
+
const sourceEntries = fs.readdirSync(sourceDir).sort();
|
|
195
|
+
const runtimeRoot = path.dirname(wrapperPath);
|
|
196
|
+
fs.mkdirSync(runtimeRoot, { recursive: true });
|
|
197
|
+
const stagingPath = fs.mkdtempSync(path.join(runtimeRoot, `.${wrapperName}.tmp-`));
|
|
198
|
+
try {
|
|
199
|
+
if (win32) {
|
|
200
|
+
// Directory junctions do not require Developer Mode or elevation and
|
|
201
|
+
// keep the runtime wrapper live against the canonical company skill.
|
|
202
|
+
// Replace the mkdtemp directory with the staged junction, then rename it
|
|
203
|
+
// into place through the same rollback-safe commit path below.
|
|
204
|
+
fs.rmSync(stagingPath, { recursive: true, force: false });
|
|
205
|
+
bridgeEntry(sourceDir, stagingPath, sourceDir, "junction", input.symlinkEntry ?? fs.symlinkSync);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
for (const entry of sourceEntries) {
|
|
209
|
+
const sourcePath = path.join(sourceDir, entry);
|
|
210
|
+
const entryWrapper = path.join(stagingPath, entry);
|
|
211
|
+
// Resolve relative to the FINAL wrapper location. The staging directory
|
|
212
|
+
// is renamed only after every entry has been bridged successfully.
|
|
213
|
+
const relativeTarget = path.relative(wrapperPath, sourcePath);
|
|
214
|
+
bridgeEntry(sourcePath, entryWrapper, relativeTarget, fs.statSync(sourcePath).isDirectory() ? "dir" : "file", input.symlinkEntry ?? fs.symlinkSync);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const currentWrapper = lstat(wrapperPath);
|
|
218
|
+
if (currentWrapper &&
|
|
219
|
+
!currentWrapper.isSymbolicLink() &&
|
|
220
|
+
!currentWrapper.isDirectory()) {
|
|
221
|
+
throw new Error(`Cannot surface the skill because '${wrapperPath}' is not a directory.`);
|
|
222
|
+
}
|
|
223
|
+
const renameEntry = input.renameEntry ?? fs.renameSync;
|
|
224
|
+
const backupPath = `${stagingPath}.previous`;
|
|
225
|
+
let previousMoved = false;
|
|
226
|
+
if (currentWrapper) {
|
|
227
|
+
if (!isGeneratedNamespacedWrapper(wrapperPath, input.hqRoot, input.companySlug)) {
|
|
228
|
+
throw new Error(`Cannot replace '${wrapperPath}' because it is not an HQ-generated skill wrapper.`);
|
|
229
|
+
}
|
|
230
|
+
renameEntry(wrapperPath, backupPath);
|
|
231
|
+
previousMoved = true;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
renameEntry(stagingPath, wrapperPath);
|
|
235
|
+
}
|
|
236
|
+
catch (replaceError) {
|
|
237
|
+
if (previousMoved && !lstat(wrapperPath)) {
|
|
238
|
+
try {
|
|
239
|
+
renameEntry(backupPath, wrapperPath);
|
|
240
|
+
}
|
|
241
|
+
catch (restoreError) {
|
|
242
|
+
throw new Error(`Could not replace '${wrapperPath}' and could not restore its previous wrapper ` +
|
|
243
|
+
`(replace: ${replaceError instanceof Error ? replaceError.message : String(replaceError)}; ` +
|
|
244
|
+
`restore: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
throw replaceError;
|
|
248
|
+
}
|
|
249
|
+
if (previousMoved) {
|
|
250
|
+
try {
|
|
251
|
+
fs.rmSync(backupPath, { recursive: true, force: true });
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// The new wrapper is already committed. A hidden generated backup is
|
|
255
|
+
// safer than rolling back a successful replacement.
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
writeGeneratedMarker(wrapperPath, input.companySlug, input.skillSlug);
|
|
259
|
+
pruneStaleNamespacedWrappers(runtimeRoot, wrapperName, input.hqRoot, input.companySlug, win32);
|
|
260
|
+
if (input.legacyPrefix) {
|
|
261
|
+
const legacyBase = path.join(runtimeRoot, `${input.legacyPrefix}-${input.skillSlug}`);
|
|
262
|
+
for (const legacyPath of [legacyBase, `${legacyBase}.md`]) {
|
|
263
|
+
if (legacyPath !== wrapperPath &&
|
|
264
|
+
isGeneratedLegacyEntry(legacyPath, sourceDir, skillFile)) {
|
|
265
|
+
fs.rmSync(legacyPath, { recursive: true, force: false });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
fs.rmSync(stagingPath, { recursive: true, force: true });
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
return { status: 0, wrapperPath };
|
|
275
|
+
}
|
|
276
|
+
//# sourceMappingURL=company-skill-wrapper.js.map
|
|
@@ -18,18 +18,22 @@ export interface IntegrationConnection extends AdminConnection {
|
|
|
18
18
|
needsReauthReason?: string;
|
|
19
19
|
/** Server-derived remediation flow; optional for older control planes. */
|
|
20
20
|
fix_path?: string;
|
|
21
|
+
/** Server-derived remediation class; optional for older control planes. */
|
|
22
|
+
fix_kind?: string;
|
|
21
23
|
}
|
|
22
24
|
export interface IntegrationsDoctorDeps {
|
|
23
25
|
ensureToken?: () => Promise<string>;
|
|
24
26
|
resolveCompany?: (token: string, company: string | undefined) => Promise<string>;
|
|
25
27
|
listConnections?: (token: string, companyUid: string) => Promise<IntegrationConnection[]>;
|
|
26
28
|
}
|
|
27
|
-
type FindingKind = "reconnect" | "re-add" | "provider-blocked";
|
|
29
|
+
type FindingKind = "reconnect" | "re-add" | "contact-admin" | "wait" | "provider-blocked" | "retryable" | "hq-configuration";
|
|
28
30
|
interface Finding {
|
|
29
31
|
provider: string;
|
|
30
32
|
connectionId: string;
|
|
31
33
|
kind: FindingKind;
|
|
32
34
|
message: string;
|
|
35
|
+
/** Optional HQ-authored remediation returned by the control plane. */
|
|
36
|
+
remediation?: string;
|
|
33
37
|
}
|
|
34
38
|
/**
|
|
35
39
|
* Expected session/company prerequisites degrade to NA instead of crashing or
|
|
@@ -14,6 +14,18 @@ import { getCompanyUid } from "../../../utils/vault-api.js";
|
|
|
14
14
|
import { bareProvider, fetchConnections, IntegrationsCliError, } from "../../../commands/integrations-core.js";
|
|
15
15
|
export const INTEGRATIONS_FAMILY_ID = "integrations";
|
|
16
16
|
export const INTEGRATIONS_PREFIX = "integrations";
|
|
17
|
+
const RECONNECT_REASON_CODES = new Set([
|
|
18
|
+
"oauth_refresh_invalid_grant",
|
|
19
|
+
"oauth_refresh_unavailable",
|
|
20
|
+
"token_refresh_failed",
|
|
21
|
+
"credentials_rejected",
|
|
22
|
+
]);
|
|
23
|
+
const RETRYABLE_REASON_CODES = new Set([
|
|
24
|
+
"oauth_refresh_transient",
|
|
25
|
+
"oauth_refresh_write_conflict",
|
|
26
|
+
]);
|
|
27
|
+
const HQ_CONFIGURATION_REASON_CODE = "oauth_client_secret_unavailable";
|
|
28
|
+
const UNSPECIFIED_REASON_CODE = "unspecified";
|
|
17
29
|
/**
|
|
18
30
|
* Expected session/company prerequisites degrade to NA instead of crashing or
|
|
19
31
|
* becoming a false connection failure. An unreadable inventory remains UNKNOWN.
|
|
@@ -89,15 +101,40 @@ export function classifyConnection(connection) {
|
|
|
89
101
|
const provider = bareProvider(connection.provider);
|
|
90
102
|
const reason = recordedReason(connection);
|
|
91
103
|
const connectedWithRecordedFailure = connection.status === "connected" && reason !== "";
|
|
104
|
+
const knownReasonCode = knownReasonCodeFor(connection);
|
|
105
|
+
// `unspecified` is a recognised member of the server vocabulary, but it is an
|
|
106
|
+
// absence of diagnosis rather than one. Short-circuiting on it here would skip
|
|
107
|
+
// the status-aware branches below and report a degraded connection as an error
|
|
108
|
+
// needing a reconnect. It is deliberately allowed to fall through, where the
|
|
109
|
+
// server's role- and credential-aware remediation is applied like any other
|
|
110
|
+
// undiagnosed row.
|
|
111
|
+
if (knownReasonCode && knownReasonCode !== UNSPECIFIED_REASON_CODE) {
|
|
112
|
+
const finding = findingForKnownReasonCode(connection, provider, knownReasonCode);
|
|
113
|
+
// Retryable and HQ-configuration reason codes diagnose conditions that a
|
|
114
|
+
// caller-specific remediation cannot change. Reconnect-class codes defer
|
|
115
|
+
// to the server's credential/role-aware remediation classification, so an
|
|
116
|
+
// install in progress is never interrupted by a reconnect.
|
|
117
|
+
return [withServerRemediation(connection, RECONNECT_REASON_CODES.has(knownReasonCode)
|
|
118
|
+
? findingForServerFixKind(finding, connection.fix_kind)
|
|
119
|
+
: finding)];
|
|
120
|
+
}
|
|
121
|
+
// A stale remediation value does not make a clean connected row unhealthy.
|
|
122
|
+
if (!connectedWithRecordedFailure && connection.status === "connected")
|
|
123
|
+
return [];
|
|
92
124
|
if (isProviderBlocked(reason) || connection.status === "degraded") {
|
|
93
|
-
return [{
|
|
125
|
+
return [withServerRemediation(connection, {
|
|
94
126
|
provider,
|
|
95
127
|
connectionId: connection.id,
|
|
96
128
|
kind: "provider-blocked",
|
|
97
129
|
message: connectedWithRecordedFailure
|
|
98
130
|
? "reports connected, but recorded provider health says access is blocked upstream"
|
|
99
131
|
: "provider-side access is blocked or unavailable",
|
|
100
|
-
}];
|
|
132
|
+
})];
|
|
133
|
+
}
|
|
134
|
+
const fallback = genericReconnectFinding(connection, provider);
|
|
135
|
+
const serverClassified = findingForServerFixKind(fallback, connection.fix_kind);
|
|
136
|
+
if (serverClassified.kind !== "reconnect") {
|
|
137
|
+
return [withServerRemediation(connection, serverClassified)];
|
|
101
138
|
}
|
|
102
139
|
if (isTokenRefreshFailure(reason) || isCredentialFailure(reason)) {
|
|
103
140
|
const problem = isTokenRefreshFailure(reason)
|
|
@@ -105,49 +142,115 @@ export function classifyConnection(connection) {
|
|
|
105
142
|
: connectedWithRecordedFailure
|
|
106
143
|
? "reports connected, but the provider rejected the stored credentials"
|
|
107
144
|
: "the provider rejected the stored credentials";
|
|
108
|
-
return [
|
|
109
|
-
}
|
|
110
|
-
if (connection.status !== "connected" && connection.fix_path === "blocked-upstream") {
|
|
111
|
-
return [{
|
|
112
|
-
provider,
|
|
113
|
-
connectionId: connection.id,
|
|
114
|
-
kind: "provider-blocked",
|
|
115
|
-
message: "provider-side access is blocked or unavailable",
|
|
116
|
-
}];
|
|
117
|
-
}
|
|
118
|
-
// The server is authoritative about an API-key credential that must be
|
|
119
|
-
// re-entered. Do not apply a stale fix path to a clean connected row, but do
|
|
120
|
-
// preserve this useful branch logic for a non-healthy row.
|
|
121
|
-
if (connection.status !== "connected" && connection.fix_path === "re-add") {
|
|
122
|
-
return [{
|
|
145
|
+
return [withServerRemediation(connection, {
|
|
123
146
|
provider,
|
|
124
147
|
connectionId: connection.id,
|
|
125
|
-
kind: "
|
|
126
|
-
message:
|
|
127
|
-
}];
|
|
148
|
+
kind: "reconnect",
|
|
149
|
+
message: problem,
|
|
150
|
+
})];
|
|
128
151
|
}
|
|
129
152
|
if (connection.status === "needs-reauth" || connection.status === "error") {
|
|
130
|
-
return [
|
|
131
|
-
provider,
|
|
132
|
-
connectionId: connection.id,
|
|
133
|
-
kind: "reconnect",
|
|
134
|
-
message: connection.status === "needs-reauth" ? "needs re-authentication" : "is in an error state",
|
|
135
|
-
}];
|
|
153
|
+
return [withServerRemediation(connection, fallback)];
|
|
136
154
|
}
|
|
137
155
|
if (connection.status !== "connected") {
|
|
138
|
-
return [{
|
|
156
|
+
return [withServerRemediation(connection, {
|
|
139
157
|
provider,
|
|
140
158
|
connectionId: connection.id,
|
|
141
159
|
kind: "reconnect",
|
|
142
160
|
message: `reports an unrecognized non-healthy status (${connection.status})`,
|
|
143
|
-
}];
|
|
161
|
+
})];
|
|
144
162
|
}
|
|
145
163
|
return [];
|
|
146
164
|
}
|
|
165
|
+
function genericReconnectFinding(connection, provider) {
|
|
166
|
+
return {
|
|
167
|
+
provider,
|
|
168
|
+
connectionId: connection.id,
|
|
169
|
+
kind: "reconnect",
|
|
170
|
+
message: connection.status === "needs-reauth"
|
|
171
|
+
? "needs re-authentication"
|
|
172
|
+
: "is in an error state",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** Only values emitted by hq-pro affect the classification; future values fall back. */
|
|
176
|
+
function findingForServerFixKind(fallback, fixKind) {
|
|
177
|
+
switch (fixKind) {
|
|
178
|
+
case "re-add":
|
|
179
|
+
return {
|
|
180
|
+
...fallback,
|
|
181
|
+
kind: "re-add",
|
|
182
|
+
message: "requires its API key to be re-entered",
|
|
183
|
+
};
|
|
184
|
+
case "contact-admin":
|
|
185
|
+
return {
|
|
186
|
+
...fallback,
|
|
187
|
+
kind: "contact-admin",
|
|
188
|
+
message: "must be repaired by a company owner or admin",
|
|
189
|
+
};
|
|
190
|
+
case "wait":
|
|
191
|
+
return {
|
|
192
|
+
...fallback,
|
|
193
|
+
kind: "wait",
|
|
194
|
+
message: "is waiting for the Factory installation to complete",
|
|
195
|
+
};
|
|
196
|
+
case "reconnect":
|
|
197
|
+
default:
|
|
198
|
+
return fallback;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* A fix path is server-authored, role- and credential-aware wording. It cannot
|
|
203
|
+
* make a connection unhealthy: the caller must first have classified a health
|
|
204
|
+
* signal, so a stale fix path on an otherwise clean connected row is ignored.
|
|
205
|
+
*/
|
|
206
|
+
function withServerRemediation(connection, finding) {
|
|
207
|
+
return connection.fix_path
|
|
208
|
+
? { ...finding, remediation: connection.fix_path }
|
|
209
|
+
: finding;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* These are a closed hq-pro contract. Exact matching deliberately comes before
|
|
213
|
+
* the legacy text heuristics, which remain below for old and unknown rows.
|
|
214
|
+
*/
|
|
215
|
+
function knownReasonCodeFor(connection) {
|
|
216
|
+
const values = [connection.errorReason, connection.needsReauthReason, connection.degradedReason];
|
|
217
|
+
if (values.includes(HQ_CONFIGURATION_REASON_CODE))
|
|
218
|
+
return HQ_CONFIGURATION_REASON_CODE;
|
|
219
|
+
return values.find((value) => (typeof value === "string" &&
|
|
220
|
+
(RECONNECT_REASON_CODES.has(value) ||
|
|
221
|
+
RETRYABLE_REASON_CODES.has(value) ||
|
|
222
|
+
value === UNSPECIFIED_REASON_CODE)));
|
|
223
|
+
}
|
|
224
|
+
function findingForKnownReasonCode(connection, provider, code) {
|
|
225
|
+
if (RECONNECT_REASON_CODES.has(code)) {
|
|
226
|
+
return {
|
|
227
|
+
provider,
|
|
228
|
+
connectionId: connection.id,
|
|
229
|
+
kind: "reconnect",
|
|
230
|
+
message: "stored credentials need re-authentication",
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (RETRYABLE_REASON_CODES.has(code)) {
|
|
234
|
+
return {
|
|
235
|
+
provider,
|
|
236
|
+
connectionId: connection.id,
|
|
237
|
+
kind: "retryable",
|
|
238
|
+
message: code === "oauth_refresh_write_conflict"
|
|
239
|
+
? "token refresh lost a concurrent write race; the stored credential remains intact"
|
|
240
|
+
: "token refresh is temporarily unavailable; the stored credential remains intact",
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
provider,
|
|
245
|
+
connectionId: connection.id,
|
|
246
|
+
kind: "hq-configuration",
|
|
247
|
+
message: "the HQ OAuth client secret is unavailable",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
147
250
|
function groupFindings(findings, company) {
|
|
148
251
|
const groups = new Map();
|
|
149
252
|
for (const finding of findings) {
|
|
150
|
-
const key = `${finding.kind}\u0000${finding.provider}\u0000${finding.message}`;
|
|
253
|
+
const key = `${finding.kind}\u0000${finding.provider}\u0000${finding.message}\u0000${finding.remediation ?? ""}`;
|
|
151
254
|
const entries = groups.get(key) ?? [];
|
|
152
255
|
entries.push(finding);
|
|
153
256
|
groups.set(key, entries);
|
|
@@ -164,12 +267,14 @@ function resultForGroup(entries, company) {
|
|
|
164
267
|
const overflow = ids.length - preview.length;
|
|
165
268
|
const namedConnections = preview.join(", ") + (overflow > 0 ? ` (+${overflow} more)` : "");
|
|
166
269
|
const plural = count === 1 ? "connection" : "connections";
|
|
270
|
+
const serverRemediation = first.remediation;
|
|
167
271
|
if (first.kind === "provider-blocked") {
|
|
168
272
|
return {
|
|
169
273
|
status: "FAIL",
|
|
170
274
|
checkId: `${INTEGRATIONS_PREFIX}.provider-blocked.${first.provider}`,
|
|
171
275
|
target: namedConnections,
|
|
172
276
|
message: `${first.provider}: ${count} ${plural} ${first.message}. This is not reported as a local credential repair.`,
|
|
277
|
+
...(serverRemediation ? { remediation: serverRemediation } : {}),
|
|
173
278
|
};
|
|
174
279
|
}
|
|
175
280
|
if (first.kind === "re-add") {
|
|
@@ -178,7 +283,43 @@ function resultForGroup(entries, company) {
|
|
|
178
283
|
checkId: `${INTEGRATIONS_PREFIX}.re-add.${first.provider}`,
|
|
179
284
|
target: namedConnections,
|
|
180
285
|
message: `${first.provider}: ${count} ${plural} ${first.message}.`,
|
|
181
|
-
remediation: `hq integrations connect ${first.provider} --token-stdin${company ? ` --company ${company}` : ""}`,
|
|
286
|
+
remediation: serverRemediation ?? `hq integrations connect ${first.provider} --token-stdin${company ? ` --company ${company}` : ""}`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (first.kind === "contact-admin") {
|
|
290
|
+
return {
|
|
291
|
+
status: "FAIL",
|
|
292
|
+
checkId: `${INTEGRATIONS_PREFIX}.contact-admin.${first.provider}`,
|
|
293
|
+
target: namedConnections,
|
|
294
|
+
message: `${first.provider}: ${count} ${plural} ${first.message}.`,
|
|
295
|
+
remediation: serverRemediation ?? "Ask a company owner or admin to repair this integration.",
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
if (first.kind === "retryable") {
|
|
299
|
+
return {
|
|
300
|
+
status: "WARN",
|
|
301
|
+
checkId: `${INTEGRATIONS_PREFIX}.retryable.${first.provider}`,
|
|
302
|
+
target: namedConnections,
|
|
303
|
+
message: `${first.provider}: ${count} ${plural} ${first.message}. Reconnecting is not needed; retry the operation later.`,
|
|
304
|
+
...(serverRemediation ? { remediation: serverRemediation } : {}),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if (first.kind === "wait") {
|
|
308
|
+
return {
|
|
309
|
+
status: "WARN",
|
|
310
|
+
checkId: `${INTEGRATIONS_PREFIX}.wait.${first.provider}`,
|
|
311
|
+
target: namedConnections,
|
|
312
|
+
message: `${first.provider}: ${count} ${plural} ${first.message}. Wait for it to complete, then retry the operation.`,
|
|
313
|
+
remediation: serverRemediation ?? "Wait for the Factory installation to complete, then retry the operation.",
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (first.kind === "hq-configuration") {
|
|
317
|
+
return {
|
|
318
|
+
status: "FAIL",
|
|
319
|
+
checkId: `${INTEGRATIONS_PREFIX}.hq-configuration.${first.provider}`,
|
|
320
|
+
target: namedConnections,
|
|
321
|
+
message: `${first.provider}: ${count} ${plural} ${first.message}. An HQ administrator must repair this configuration; reconnecting will not fix it.`,
|
|
322
|
+
...(serverRemediation ? { remediation: serverRemediation } : {}),
|
|
182
323
|
};
|
|
183
324
|
}
|
|
184
325
|
const companyArg = company ? ` --company ${company}` : "";
|
|
@@ -190,9 +331,9 @@ function resultForGroup(entries, company) {
|
|
|
190
331
|
checkId: `${INTEGRATIONS_PREFIX}.reconnect.${first.provider}`,
|
|
191
332
|
target: namedConnections,
|
|
192
333
|
message: `${first.provider}: ${count} ${plural} ${first.message}.`,
|
|
193
|
-
remediation: overflow > 0
|
|
334
|
+
remediation: serverRemediation ?? (overflow > 0
|
|
194
335
|
? `Reconnect the listed connections, then repeat for the remaining ${overflow}: ${remediation}`
|
|
195
|
-
: remediation,
|
|
336
|
+
: remediation),
|
|
196
337
|
};
|
|
197
338
|
}
|
|
198
339
|
function recordedReason(connection) {
|
|
@@ -108,6 +108,28 @@ export declare class QmdTerminatedError extends QmdExitError {
|
|
|
108
108
|
name: string;
|
|
109
109
|
constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, signal: string);
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* qmd refused an LLM-backed operation because its local LLM is DISABLED IN CI:
|
|
113
|
+
* qmd hard-disables every LLM operation whenever `CI` is truthy in the
|
|
114
|
+
* environment and throws `Error: LLM operations are disabled in CI (set
|
|
115
|
+
* CI=true)`. Its semantic/hybrid reads (`vsearch`/`query`) expand the query
|
|
116
|
+
* through that gate, and the index build (`embed`) generates vectors through it,
|
|
117
|
+
* so any of them dies immediately when CI is set. hq-cli forwards the caller's
|
|
118
|
+
* environment — including CI — to the qmd child, so this is the caller's
|
|
119
|
+
* ENVIRONMENT, not an hq-cli defect.
|
|
120
|
+
*
|
|
121
|
+
* Before this class the refusal rendered as `qmd <full argv> exited with 1:
|
|
122
|
+
* <qmd's stderr>` and, because that message interpolated the caller's whole
|
|
123
|
+
* argv, every distinct search query minted a brand-new permanent Sentry issue —
|
|
124
|
+
* the same unbounded-fingerprint failure fixed for HQ-CLI-S and HQ-CLI
|
|
125
|
+
* 7677702704. This typed subclass lets the boundary classify the condition as
|
|
126
|
+
* the caller's environment and stop fingerprinting on the query text: it is
|
|
127
|
+
* raised only from qmd's OWN captured streams and its message names the
|
|
128
|
+
* SUBCOMMAND only. Sentry HQ-CLI 7688850003.
|
|
129
|
+
*/
|
|
130
|
+
export declare class QmdLlmDisabledError extends QmdExitError {
|
|
131
|
+
name: string;
|
|
132
|
+
}
|
|
111
133
|
export type ResolveQmdBinOptions = {
|
|
112
134
|
env?: Record<string, string | undefined>;
|
|
113
135
|
isExecutable?: (candidate: string) => boolean;
|
|
@@ -86,6 +86,28 @@ export class QmdTerminatedError extends QmdExitError {
|
|
|
86
86
|
this.signal = signal;
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* qmd refused an LLM-backed operation because its local LLM is DISABLED IN CI:
|
|
91
|
+
* qmd hard-disables every LLM operation whenever `CI` is truthy in the
|
|
92
|
+
* environment and throws `Error: LLM operations are disabled in CI (set
|
|
93
|
+
* CI=true)`. Its semantic/hybrid reads (`vsearch`/`query`) expand the query
|
|
94
|
+
* through that gate, and the index build (`embed`) generates vectors through it,
|
|
95
|
+
* so any of them dies immediately when CI is set. hq-cli forwards the caller's
|
|
96
|
+
* environment — including CI — to the qmd child, so this is the caller's
|
|
97
|
+
* ENVIRONMENT, not an hq-cli defect.
|
|
98
|
+
*
|
|
99
|
+
* Before this class the refusal rendered as `qmd <full argv> exited with 1:
|
|
100
|
+
* <qmd's stderr>` and, because that message interpolated the caller's whole
|
|
101
|
+
* argv, every distinct search query minted a brand-new permanent Sentry issue —
|
|
102
|
+
* the same unbounded-fingerprint failure fixed for HQ-CLI-S and HQ-CLI
|
|
103
|
+
* 7677702704. This typed subclass lets the boundary classify the condition as
|
|
104
|
+
* the caller's environment and stop fingerprinting on the query text: it is
|
|
105
|
+
* raised only from qmd's OWN captured streams and its message names the
|
|
106
|
+
* SUBCOMMAND only. Sentry HQ-CLI 7688850003.
|
|
107
|
+
*/
|
|
108
|
+
export class QmdLlmDisabledError extends QmdExitError {
|
|
109
|
+
name = 'QmdLlmDisabledError';
|
|
110
|
+
}
|
|
89
111
|
function isExecutable(candidate) {
|
|
90
112
|
try {
|
|
91
113
|
fs.accessSync(candidate, fs.constants.X_OK);
|
|
@@ -730,6 +752,21 @@ function finishRunQmd(result, bin, args) {
|
|
|
730
752
|
}
|
|
731
753
|
const detail = normalized.stderr || normalized.stdout || 'qmd returned no diagnostic output';
|
|
732
754
|
const message = `qmd ${args.join(' ')} exited with ${normalized.status ?? 'an unknown status'}: ${detail}`;
|
|
755
|
+
// qmd refused because its local LLM is disabled: whenever CI is set in the
|
|
756
|
+
// environment, qmd hard-disables every LLM operation, so `vsearch`/`query`
|
|
757
|
+
// (query expansion) and `embed` (vector build) throw `Error: LLM operations
|
|
758
|
+
// are disabled in CI (set CI=true)` before doing any work. hq-cli inherits the
|
|
759
|
+
// caller's CI into the qmd child (runQmd forwards process.env), so this is the
|
|
760
|
+
// caller's ENVIRONMENT, not an hq-cli defect. Match qmd's OWN captured streams
|
|
761
|
+
// (stderr AND stdout) — never the synthesized `message`, which embeds the
|
|
762
|
+
// caller's whole argv — and name the SUBCOMMAND only, so the Sentry group can
|
|
763
|
+
// no longer fingerprint per search query (HQ-CLI 7688850003). Checked ahead of
|
|
764
|
+
// the collection wordings: the refusal text matches none of them, so order is
|
|
765
|
+
// safe, and the narrower typed signature stays first.
|
|
766
|
+
if (/LLM operations are disabled/i.test(`${normalized.stderr}\n${normalized.stdout}`)) {
|
|
767
|
+
const subcommand = typeof args[0] === 'string' && args[0].length > 0 ? redactErrorText(args[0]) || 'qmd' : 'qmd';
|
|
768
|
+
throw new QmdLlmDisabledError(`qmd ${subcommand} could not run: its local LLM is disabled because CI is set in the environment`, args, normalized.status, normalized.stdout, normalized.stderr);
|
|
769
|
+
}
|
|
733
770
|
if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(detail)) {
|
|
734
771
|
throw new QmdCollectionMissingError(message, args, normalized.status, normalized.stdout, normalized.stderr);
|
|
735
772
|
}
|
package/dist/main.js
CHANGED
|
@@ -68,6 +68,7 @@ import { networkTransportErrorMessage } from "./utils/network-transport-error.js
|
|
|
68
68
|
import { qmdNativeBindingErrorMessage } from "./utils/qmd-native-binding-error.js";
|
|
69
69
|
import { qmdMissingCollectionMessage } from "./utils/qmd-collection-missing-error.js";
|
|
70
70
|
import { qmdTerminatedMessage } from "./utils/qmd-terminated-error.js";
|
|
71
|
+
import { qmdLlmDisabledMessage } from "./utils/qmd-llm-disabled-error.js";
|
|
71
72
|
import { isExpectedUserError } from "./utils/expected-cli-error.js";
|
|
72
73
|
import { isEpipe } from "./utils/epipe.js";
|
|
73
74
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
@@ -486,7 +487,23 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
486
487
|
// collection-missing checks (both narrower typed signatures) and BEFORE the
|
|
487
488
|
// environmental / transport / generic branches.
|
|
488
489
|
const terminatedMsg = qmdMsg || collectionMsg ? null : qmdTerminatedMessage(err);
|
|
489
|
-
|
|
490
|
+
// A qmd LLM-backed operation refused because qmd's local LLM is DISABLED
|
|
491
|
+
// whenever CI is set in the environment (`vsearch`/`query` expand the
|
|
492
|
+
// query through it; `embed` builds vectors through it). hq-cli forwards the
|
|
493
|
+
// caller's CI to the qmd child, so this is the caller's ENVIRONMENT, not an
|
|
494
|
+
// hq-cli defect. Before this branch the refusal reached the final else and,
|
|
495
|
+
// because the synthesized message embedded the caller's whole argv, minted
|
|
496
|
+
// a brand-new permanent issue per query (HQ-CLI 7688850003). finishRunQmd
|
|
497
|
+
// now types it QmdLlmDisabledError; print the query-free remedy and skip
|
|
498
|
+
// capture. Evaluated AFTER the native-binding / collection-missing /
|
|
499
|
+
// terminated checks (narrower typed signatures) and BEFORE the
|
|
500
|
+
// environmental / transport / generic branches. Scoped to the subcommands
|
|
501
|
+
// that legitimately need the LLM (vsearch/query/embed); an LLM-disabled
|
|
502
|
+
// failure from hq's OWN reconciliation stays a captured internal error.
|
|
503
|
+
const llmDisabledMsg = qmdMsg || collectionMsg || terminatedMsg ? null : qmdLlmDisabledMessage(err);
|
|
504
|
+
const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg
|
|
505
|
+
? null
|
|
506
|
+
: environmentalFsErrorMessage(err);
|
|
490
507
|
// A raw network transport failure (undici's `TypeError: fetch failed`
|
|
491
508
|
// with a ConnectTimeoutError / ECONNREFUSED / ENOTFOUND cause) is the
|
|
492
509
|
// caller's connectivity, not an hq-cli defect. Before this branch it fell
|
|
@@ -497,7 +514,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
497
514
|
// message that names the unreachable host, exit 1, and skip Sentry.
|
|
498
515
|
// Ordered after the environmental check so a full disk keeps its exact
|
|
499
516
|
// existing message.
|
|
500
|
-
const transportMsg = qmdMsg || collectionMsg || terminatedMsg ||
|
|
517
|
+
const transportMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || envMsg
|
|
518
|
+
? null
|
|
519
|
+
: networkTransportErrorMessage(err);
|
|
501
520
|
if (qmdMsg) {
|
|
502
521
|
deps.stderr.write(`hq: ${qmdMsg}\n`);
|
|
503
522
|
}
|
|
@@ -507,6 +526,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
507
526
|
else if (terminatedMsg) {
|
|
508
527
|
deps.stderr.write(`hq: ${terminatedMsg}\n`);
|
|
509
528
|
}
|
|
529
|
+
else if (llmDisabledMsg) {
|
|
530
|
+
deps.stderr.write(`hq: ${llmDisabledMsg}\n`);
|
|
531
|
+
}
|
|
510
532
|
else if (envMsg) {
|
|
511
533
|
deps.stderr.write(`hq: ${envMsg}\n`);
|
|
512
534
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* If `err` is a qmd LLM-disabled-in-CI failure on a subcommand that legitimately
|
|
3
|
+
* needs the LLM, return an actionable, query-free remedy; otherwise return
|
|
4
|
+
* `null`. Mirrors qmdTerminatedMessage / qmdMissingCollectionMessage /
|
|
5
|
+
* qmdNativeBindingErrorMessage so the top-level handler branches the same way: a
|
|
6
|
+
* non-null result means print-and-skip-Sentry, null means "handle as usual
|
|
7
|
+
* (capture to Sentry)".
|
|
8
|
+
*/
|
|
9
|
+
export declare function qmdLlmDisabledMessage(err: unknown): string | null;
|
|
10
|
+
//# sourceMappingURL=qmd-llm-disabled-error.d.ts.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// src/utils/qmd-llm-disabled-error.ts
|
|
2
|
+
//
|
|
3
|
+
// Classify a qmd failure caused by qmd's local LLM being DISABLED IN CI — not an
|
|
4
|
+
// hq-cli code defect. qmd hard-disables every LLM operation whenever `CI` is
|
|
5
|
+
// truthy in the environment (`Error: LLM operations are disabled in CI (set
|
|
6
|
+
// CI=true)`); its semantic/hybrid reads (`vsearch`/`query`) expand the query
|
|
7
|
+
// through that gate and the index build (`embed`) generates embeddings through
|
|
8
|
+
// it, so each dies immediately when CI is set. hq-cli forwards the caller's
|
|
9
|
+
// environment — including CI — to the qmd child, so this is the caller's
|
|
10
|
+
// ENVIRONMENT, not a bug HQ can fix in code: surface an actionable remedy and
|
|
11
|
+
// SKIP Sentry capture. Sibling of qmd-terminated-error.ts (HQ-CLI 7677702704),
|
|
12
|
+
// qmd-collection-missing-error.ts (HQ-CLI-S), qmd-native-binding-error.ts
|
|
13
|
+
// (HQ-CLI-J), environmental-error.ts (HQ-CLI-2) and network-transport-error.ts
|
|
14
|
+
// (HQ-CLI-G): a failure that is NOT an hq-cli defect is printed with an
|
|
15
|
+
// actionable message and never filed as a crash.
|
|
16
|
+
//
|
|
17
|
+
// HQ-CLI 7688850003: `hq search <query> --mode semantic ...` spawned `qmd
|
|
18
|
+
// vsearch`, which refused because CI was set on the host (an automation
|
|
19
|
+
// harness). hq-cli had no classifier for that wording, so finishRunQmd raised a
|
|
20
|
+
// plain QmdExitError whose synthesized message interpolates the caller's whole
|
|
21
|
+
// argv; nothing caught it, so it reached the top-level handler's final else and
|
|
22
|
+
// was captured — and because the message embeds the query, every distinct query
|
|
23
|
+
// minted a brand-new permanent Sentry issue. finishRunQmd now types the
|
|
24
|
+
// condition QmdLlmDisabledError (raised only from qmd's OWN captured streams,
|
|
25
|
+
// message naming the subcommand only); this classifier closes the boundary.
|
|
26
|
+
//
|
|
27
|
+
// The gate is deliberately narrow on TWO axes so it can neither be tripped by
|
|
28
|
+
// user input nor silence a real bug:
|
|
29
|
+
// 1. CLASS: only a QmdLlmDisabledError (the typed subclass finishRunQmd raises
|
|
30
|
+
// for the LLM-disabled wording) qualifies — never a plain QmdExitError, a
|
|
31
|
+
// native-binding failure, or any other error, even one carrying the same
|
|
32
|
+
// wording in a user-controlled field.
|
|
33
|
+
// 2. INVOCATION: only the qmd subcommands that legitimately need the LLM — the
|
|
34
|
+
// caller-supplied semantic/hybrid reads `vsearch`/`query`, plus the index
|
|
35
|
+
// build `embed`. Any other subcommand (e.g. an LLM-disabled failure surfaced
|
|
36
|
+
// by hq's OWN `collection list` reconciliation) returns null and stays on
|
|
37
|
+
// the captured-error path, so a condition hq did not expect is still
|
|
38
|
+
// reported — now grouped per subcommand rather than per query.
|
|
39
|
+
//
|
|
40
|
+
// The remedy is entirely query-free: only the SUBCOMMAND — drawn from a closed
|
|
41
|
+
// allow-list — selects the wording, and NOTHING is interpolated from user input,
|
|
42
|
+
// so there is no injection surface at all. This preserves the bounded-
|
|
43
|
+
// fingerprint doctrine established by HQ-CLI-S and HQ-CLI 7677702704.
|
|
44
|
+
/** Semantic/hybrid reads whose query is EXPANDED through qmd's LLM gate. */
|
|
45
|
+
const LLM_SEARCH_READS = new Set(["vsearch", "query"]);
|
|
46
|
+
/** The index build that generates embeddings through the same LLM gate. */
|
|
47
|
+
const LLM_INDEX_BUILD = "embed";
|
|
48
|
+
/**
|
|
49
|
+
* Remedy for a semantic/hybrid SEARCH read. Leads with `--mode keyword`, which
|
|
50
|
+
* needs no LLM and is correct in EVERY environment (including a genuine CI
|
|
51
|
+
* pipeline where clearing CI is not an option), and offers clearing CI only as
|
|
52
|
+
* the secondary option for a non-CI host that merely has the variable set.
|
|
53
|
+
*/
|
|
54
|
+
const SEARCH_REMEDY = "Semantic and hybrid search need qmd's local LLM, which is switched off " +
|
|
55
|
+
"because CI is set in this environment. Re-run with '--mode keyword' (it needs " +
|
|
56
|
+
"no LLM and works everywhere), or clear CI for the command and try again.";
|
|
57
|
+
/**
|
|
58
|
+
* Remedy for the `embed` index build, which cannot run at all without the LLM —
|
|
59
|
+
* so `--mode keyword` does not apply and the only fix is to clear CI.
|
|
60
|
+
*/
|
|
61
|
+
const EMBED_REMEDY = "Embeddings can't be built while CI is set in this environment, because qmd " +
|
|
62
|
+
"disables its local LLM there. Clear CI for the command and run it again.";
|
|
63
|
+
/**
|
|
64
|
+
* If `err` is a qmd LLM-disabled-in-CI failure on a subcommand that legitimately
|
|
65
|
+
* needs the LLM, return an actionable, query-free remedy; otherwise return
|
|
66
|
+
* `null`. Mirrors qmdTerminatedMessage / qmdMissingCollectionMessage /
|
|
67
|
+
* qmdNativeBindingErrorMessage so the top-level handler branches the same way: a
|
|
68
|
+
* non-null result means print-and-skip-Sentry, null means "handle as usual
|
|
69
|
+
* (capture to Sentry)".
|
|
70
|
+
*/
|
|
71
|
+
export function qmdLlmDisabledMessage(err) {
|
|
72
|
+
if (err === null || typeof err !== "object")
|
|
73
|
+
return null;
|
|
74
|
+
const record = err;
|
|
75
|
+
if (record.name !== "QmdLlmDisabledError")
|
|
76
|
+
return null;
|
|
77
|
+
const args = Array.isArray(record.args) ? record.args : [];
|
|
78
|
+
const subcommand = args[0];
|
|
79
|
+
if (typeof subcommand !== "string")
|
|
80
|
+
return null;
|
|
81
|
+
if (LLM_SEARCH_READS.has(subcommand))
|
|
82
|
+
return SEARCH_REMEDY;
|
|
83
|
+
if (subcommand === LLM_INDEX_BUILD)
|
|
84
|
+
return EMBED_REMEDY;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=qmd-llm-disabled-error.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.103.
|
|
3
|
+
"version": "5.103.27",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
32
32
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
33
|
-
"@indigoai-us/hq-cloud": "~6.15.
|
|
33
|
+
"@indigoai-us/hq-cloud": "~6.15.80",
|
|
34
34
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
35
35
|
"@sentry/node": "^10.49.0",
|
|
36
36
|
"@tobilu/qmd": "2.5.3",
|