@indigoai-us/hq-cli 5.103.22 → 5.103.24
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/skill.d.ts +44 -142
- package/dist/commands/skill.js +214 -521
- package/dist/main.js +18 -3
- package/dist/utils/browser-login-abandoned.d.ts +12 -0
- package/dist/utils/browser-login-abandoned.js +70 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.103.24] — 2026-08-27
|
|
6
|
+
|
|
7
|
+
## [5.103.23] — 2026-08-26
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- The bundled sync engine now requires `@indigoai-us/hq-cloud` 6.15.71, so new
|
|
12
|
+
company skills are registered and stamped with a stable skill UID before
|
|
13
|
+
their first upload while comment-only skill proposals remain available from
|
|
14
|
+
the CLI.
|
|
15
|
+
|
|
5
16
|
## [5.103.22] — 2026-08-25
|
|
6
17
|
|
|
7
18
|
### Fixed
|
package/dist/commands/skill.d.ts
CHANGED
|
@@ -1,153 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Company skill creation and comment-only improvements.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* This is a THIN front-end over the SAME wired hq-pro routes the MCP surface
|
|
11
|
-
* (US-007) and the console merge path (US-009) use — there is NO forked
|
|
12
|
-
* suggestion or merge logic here. The CLI reads the local working SKILL.md,
|
|
13
|
-
* computes the proposed content (+ its base for a diff), and POSTs to:
|
|
14
|
-
*
|
|
15
|
-
* CREATE POST /v1/files/skills/company/{slug}/{skillUid}/suggestions
|
|
16
|
-
* LIST POST /v1/files/skills/company/{slug}/suggestions/list
|
|
17
|
-
* ACCEPT POST /v1/files/skills/company/{slug}/{skillUid}/suggestions/{id}/accept
|
|
18
|
-
* DECLINE POST /v1/files/skills/company/{slug}/{skillUid}/suggestions/{id}/decline
|
|
19
|
-
*
|
|
20
|
-
* The skill routes are keyed on the company SLUG (path param, resolved
|
|
21
|
-
* server-side via findEntityBySlug) — NOT the companyUid the vault/ACL routes
|
|
22
|
-
* use — so this module resolves a slug (from `--company` or the active company)
|
|
23
|
-
* and passes it straight through.
|
|
24
|
-
*
|
|
25
|
-
* Lock semantics (AC3): a suggest against a skill the caller cannot WRITE still
|
|
26
|
-
* SUCCEEDS as a proposal. The CREATE route is MEMBER-gated (never write-gated),
|
|
27
|
-
* so this command performs NO client-side lock/permission pre-check — it always
|
|
28
|
-
* posts and renders whatever the server returns. A locked-out member lands a
|
|
29
|
-
* suggestion, never a hard permission error.
|
|
30
|
-
*
|
|
31
|
-
* Attribution (AC4): the invoking identity (from the Cognito JWT) is the server-
|
|
32
|
-
* derived `authorPersonUid`; the CLI never sends an author. An optional
|
|
33
|
-
* `--note` rides as `authorNote` (the change's rationale / failure context).
|
|
4
|
+
* `hq skill create <slug>` registers a canonical company skill, stamps its
|
|
5
|
+
* immutable UID, reindexes its generated runtime wrapper, and syncs it.
|
|
6
|
+
* `hq skill propose <uid|path> --message "…"` posts a whole-skill comment to
|
|
7
|
+
* the same improvement thread shown in HQ Console. It never uploads a modified
|
|
8
|
+
* SKILL.md and cannot overwrite live content. Structured suggest/list/review
|
|
9
|
+
* commands intentionally are not registered.
|
|
34
10
|
*/
|
|
35
11
|
import { Command } from "commander";
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
* it); anything else is a filesystem path. Lenient on the suffix (the strict
|
|
39
|
-
* `skl_<ulid>` shape is `isSkillUid` on the server) so a hand-typed / fixture
|
|
40
|
-
* uid still routes to the uid branch.
|
|
41
|
-
*/
|
|
12
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
13
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
42
14
|
export declare const SKILL_UID_PATTERN: RegExp;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
* frontmatter, it fails to parse, or `skill_uid` is absent / not a `skl_…`
|
|
50
|
-
* string. Never throws.
|
|
51
|
-
*/
|
|
52
|
-
export declare function parseSkillUid(md: string): string | undefined;
|
|
53
|
-
/** sha256 hex of a string — the base-version fingerprint the server records (AC2). */
|
|
54
|
-
export declare function sha256Hex(content: string): string;
|
|
55
|
-
export interface SuggestionCreateBody {
|
|
56
|
-
proposedContent: string;
|
|
57
|
-
baseContent?: string;
|
|
58
|
-
baseContentHash?: string;
|
|
59
|
-
authorNote?: string;
|
|
15
|
+
export declare const SKILL_SLUG_PATTERN: RegExp;
|
|
16
|
+
interface SkillSyncInput {
|
|
17
|
+
filePath: string;
|
|
18
|
+
companySlug: string;
|
|
19
|
+
hqRoot: string;
|
|
20
|
+
token: string;
|
|
60
21
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
* (the diff-by-default path). A no-op (base === proposed) is rejected here
|
|
66
|
-
* rather than round-tripped to an EmptySuggestion 400.
|
|
67
|
-
* - no base → a `full-file` proposal; the server REQUIRES a `baseContentHash`,
|
|
68
|
-
* so we fingerprint the proposed content (a "here is my whole file" proposal
|
|
69
|
-
* with no base to diff against).
|
|
70
|
-
* An empty / whitespace-only note is dropped (kept absent, not blank).
|
|
71
|
-
*/
|
|
72
|
-
export declare function buildSuggestionCreateBody(input: {
|
|
73
|
-
proposedContent: string;
|
|
74
|
-
baseContent?: string;
|
|
75
|
-
note?: string;
|
|
76
|
-
}): SuggestionCreateBody;
|
|
77
|
-
/**
|
|
78
|
-
* Map an hq-pro skill route error to a single user-facing line. Pure so the
|
|
79
|
-
* status → copy mapping is unit-tested independently of the network. Prefers the
|
|
80
|
-
* server's own `error` / `message` (they carry the actionable specifics — e.g.
|
|
81
|
-
* "Skill not found", "You need write access to this skill…").
|
|
82
|
-
*/
|
|
83
|
-
export declare function mapSkillError(status: number, body: Record<string, unknown>): string;
|
|
84
|
-
/** LIST inbox row shape returned by `suggestionToWire` on the server. */
|
|
85
|
-
export interface SuggestionRow {
|
|
86
|
-
suggestionId: string;
|
|
87
|
-
skillUid: string;
|
|
88
|
-
authorPersonUid: string;
|
|
89
|
-
status: string;
|
|
90
|
-
baseChanged: boolean;
|
|
91
|
-
presentation?: string;
|
|
92
|
-
unifiedDiff?: string;
|
|
93
|
-
fullContent?: string;
|
|
94
|
-
baseContentHash?: string;
|
|
95
|
-
currentContentHash?: string;
|
|
96
|
-
authorNote?: string;
|
|
97
|
-
createdAt: string;
|
|
98
|
-
path: string;
|
|
22
|
+
interface SkillSyncResult {
|
|
23
|
+
filesUploaded: number;
|
|
24
|
+
filesSkipped: number;
|
|
25
|
+
aborted: boolean;
|
|
99
26
|
}
|
|
100
|
-
|
|
101
|
-
* Render the review inbox as a table (one row per suggestion), optionally
|
|
102
|
-
* printing each suggestion's unified diff (or full proposed file, when the base
|
|
103
|
-
* drifted / the proposal is full-file) beneath its row. Pure → snapshot-testable.
|
|
104
|
-
*/
|
|
105
|
-
export declare function formatSuggestionsList(suggestions: SuggestionRow[], opts?: {
|
|
106
|
-
showDiff?: boolean;
|
|
107
|
-
}): string;
|
|
108
|
-
/** Read `.hq/config.json`'s `activeCompany` (mirrors signals/sources). */
|
|
27
|
+
export declare function parseSkillUid(markdown: string): string | undefined;
|
|
109
28
|
export declare function readActiveCompanySlug(hqRoot: string): string | undefined;
|
|
110
|
-
/**
|
|
111
|
-
* The skill routes are keyed on the company SLUG. Precedence: explicit
|
|
112
|
-
* `--company` → `.hq/config.json` activeCompany. Throws with actionable copy
|
|
113
|
-
* when neither is available.
|
|
114
|
-
*/
|
|
115
29
|
export declare function resolveCompanySlug(flag: string | undefined, hqRoot?: string): string;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
30
|
+
/** Resolve a UID directly, or read the immutable UID from a local SKILL.md. */
|
|
31
|
+
export declare function resolveSkillUid(target: string, cwd: string): string;
|
|
32
|
+
export declare function canonicalCompanySkillPath(hqRoot: string, companySlug: string, skillSlug: string): string;
|
|
33
|
+
export declare function makeSkillTemplate(input: {
|
|
34
|
+
slug: string;
|
|
35
|
+
name?: string;
|
|
36
|
+
description?: string;
|
|
37
|
+
}): string;
|
|
38
|
+
/** Replace SKILL.md without exposing a partially-written identity to agents. */
|
|
39
|
+
export declare function writeSkillFileAtomically(filePath: string, content: string): void;
|
|
40
|
+
export declare function mapSkillError(status: number, body: Record<string, unknown>): string;
|
|
41
|
+
interface SkillCommandDeps {
|
|
42
|
+
ensureToken?: typeof ensureCognitoToken;
|
|
43
|
+
apiFetch?: typeof vaultApiFetch;
|
|
44
|
+
cwd?: () => string;
|
|
45
|
+
hqRoot?: string;
|
|
46
|
+
syncFile?: (input: SkillSyncInput) => Promise<SkillSyncResult>;
|
|
47
|
+
reindexFn?: (input: {
|
|
48
|
+
repoRoot: string;
|
|
49
|
+
}) => {
|
|
50
|
+
status: number | null;
|
|
51
|
+
};
|
|
123
52
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
* searching each root breadth-first with a bounded depth (skips VCS / build /
|
|
127
|
-
* dependency dirs). Returns the first match's absolute path, or null.
|
|
128
|
-
*/
|
|
129
|
-
export declare function findSkillFileByUid(roots: string[], uid: string, maxDepth?: number): string | null;
|
|
130
|
-
/**
|
|
131
|
-
* Resolve a `suggest` target (a `skl_…` uid OR a filesystem path) to the local
|
|
132
|
-
* SKILL.md, its uid, and its content. A uid is resolved by scanning the company
|
|
133
|
-
* skills dir and the cwd; a path is read directly (a directory → its SKILL.md),
|
|
134
|
-
* with the uid read from the file's frontmatter.
|
|
135
|
-
*/
|
|
136
|
-
export declare function resolveSkillTarget(target: string, deps: {
|
|
137
|
-
cwd: string;
|
|
138
|
-
hqRoot: string;
|
|
139
|
-
companySlug: string;
|
|
140
|
-
}): ResolvedSkillTarget;
|
|
141
|
-
/**
|
|
142
|
-
* Read the committed (HEAD) version of a file from its git repo — the diff base
|
|
143
|
-
* for "propose my working changes". Returns null when the file is untracked, not
|
|
144
|
-
* in a repo, or git is unavailable (the caller then falls back to full-file, or
|
|
145
|
-
* errors under `--diff`). Never throws.
|
|
146
|
-
*/
|
|
147
|
-
export declare function readGitBase(filePath: string): Promise<string | null>;
|
|
148
|
-
/** Injectable git-base seam so `suggest` is testable without a real repo. */
|
|
149
|
-
export type GitBaseReader = (filePath: string) => Promise<string | null>;
|
|
150
|
-
export declare function registerSkillCommand(program: Command, deps?: {
|
|
151
|
-
gitBase?: GitBaseReader;
|
|
152
|
-
}): Command;
|
|
53
|
+
export declare function registerSkillCommand(program: Command, deps?: SkillCommandDeps): Command;
|
|
54
|
+
export {};
|
|
153
55
|
//# sourceMappingURL=skill.d.ts.map
|
package/dist/commands/skill.js
CHANGED
|
@@ -1,226 +1,67 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Company skill creation and comment-only improvements.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* This is a THIN front-end over the SAME wired hq-pro routes the MCP surface
|
|
11
|
-
* (US-007) and the console merge path (US-009) use — there is NO forked
|
|
12
|
-
* suggestion or merge logic here. The CLI reads the local working SKILL.md,
|
|
13
|
-
* computes the proposed content (+ its base for a diff), and POSTs to:
|
|
14
|
-
*
|
|
15
|
-
* CREATE POST /v1/files/skills/company/{slug}/{skillUid}/suggestions
|
|
16
|
-
* LIST POST /v1/files/skills/company/{slug}/suggestions/list
|
|
17
|
-
* ACCEPT POST /v1/files/skills/company/{slug}/{skillUid}/suggestions/{id}/accept
|
|
18
|
-
* DECLINE POST /v1/files/skills/company/{slug}/{skillUid}/suggestions/{id}/decline
|
|
19
|
-
*
|
|
20
|
-
* The skill routes are keyed on the company SLUG (path param, resolved
|
|
21
|
-
* server-side via findEntityBySlug) — NOT the companyUid the vault/ACL routes
|
|
22
|
-
* use — so this module resolves a slug (from `--company` or the active company)
|
|
23
|
-
* and passes it straight through.
|
|
24
|
-
*
|
|
25
|
-
* Lock semantics (AC3): a suggest against a skill the caller cannot WRITE still
|
|
26
|
-
* SUCCEEDS as a proposal. The CREATE route is MEMBER-gated (never write-gated),
|
|
27
|
-
* so this command performs NO client-side lock/permission pre-check — it always
|
|
28
|
-
* posts and renders whatever the server returns. A locked-out member lands a
|
|
29
|
-
* suggestion, never a hard permission error.
|
|
30
|
-
*
|
|
31
|
-
* Attribution (AC4): the invoking identity (from the Cognito JWT) is the server-
|
|
32
|
-
* derived `authorPersonUid`; the CLI never sends an author. An optional
|
|
33
|
-
* `--note` rides as `authorNote` (the change's rationale / failure context).
|
|
4
|
+
* `hq skill create <slug>` registers a canonical company skill, stamps its
|
|
5
|
+
* immutable UID, reindexes its generated runtime wrapper, and syncs it.
|
|
6
|
+
* `hq skill propose <uid|path> --message "…"` posts a whole-skill comment to
|
|
7
|
+
* the same improvement thread shown in HQ Console. It never uploads a modified
|
|
8
|
+
* SKILL.md and cannot overwrite live content. Structured suggest/list/review
|
|
9
|
+
* commands intentionally are not registered.
|
|
34
10
|
*/
|
|
35
11
|
import * as fs from "node:fs";
|
|
36
12
|
import * as path from "node:path";
|
|
37
|
-
import { createHash } from "node:crypto";
|
|
38
13
|
import chalk from "chalk";
|
|
39
14
|
import yaml from "js-yaml";
|
|
40
|
-
import
|
|
41
|
-
import { ensureCognitoToken, DEFAULT_HQ_ROOT } from "../utils/cognito-session.js";
|
|
15
|
+
import { reindex, share } from "@indigoai-us/hq-cloud";
|
|
16
|
+
import { ensureCognitoToken, DEFAULT_HQ_ROOT, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
42
17
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
// Patterns
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
/**
|
|
47
|
-
* A `skl_…` argument to `suggest` is a skill UID (resolve the local file from
|
|
48
|
-
* it); anything else is a filesystem path. Lenient on the suffix (the strict
|
|
49
|
-
* `skl_<ulid>` shape is `isSkillUid` on the server) so a hand-typed / fixture
|
|
50
|
-
* uid still routes to the uid branch.
|
|
51
|
-
*/
|
|
52
18
|
export const SKILL_UID_PATTERN = /^skl_[A-Za-z0-9]+$/;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const VALID_LIST_STATUSES = ["open", "accepted", "declined", "all"];
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// Pure helpers (exported for unit tests)
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
19
|
+
export const SKILL_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
20
|
+
const COMPANY_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
59
21
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
22
|
+
async function defaultSyncFile(input) {
|
|
23
|
+
const result = await share({
|
|
24
|
+
paths: [input.filePath],
|
|
25
|
+
company: input.companySlug,
|
|
26
|
+
hqRoot: input.hqRoot,
|
|
27
|
+
vaultConfig: buildVaultConfig(input.token),
|
|
28
|
+
onConflict: "abort",
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
filesUploaded: result.filesUploaded,
|
|
32
|
+
filesSkipped: result.filesSkipped,
|
|
33
|
+
aborted: result.aborted,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function parseSkillUid(markdown) {
|
|
37
|
+
const match = markdown.match(FRONTMATTER_RE);
|
|
69
38
|
if (!match)
|
|
70
39
|
return undefined;
|
|
71
|
-
let doc;
|
|
72
40
|
try {
|
|
73
|
-
doc = yaml.load(match[1]);
|
|
41
|
+
const doc = yaml.load(match[1]);
|
|
42
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc))
|
|
43
|
+
return undefined;
|
|
44
|
+
const uid = doc.skill_uid;
|
|
45
|
+
return typeof uid === "string" && SKILL_UID_PATTERN.test(uid) ? uid : undefined;
|
|
74
46
|
}
|
|
75
47
|
catch {
|
|
76
48
|
return undefined;
|
|
77
49
|
}
|
|
78
|
-
if (!doc || typeof doc !== "object" || Array.isArray(doc))
|
|
79
|
-
return undefined;
|
|
80
|
-
const uid = doc.skill_uid;
|
|
81
|
-
return typeof uid === "string" && SKILL_UID_PATTERN.test(uid) ? uid : undefined;
|
|
82
|
-
}
|
|
83
|
-
/** sha256 hex of a string — the base-version fingerprint the server records (AC2). */
|
|
84
|
-
export function sha256Hex(content) {
|
|
85
|
-
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Build the CREATE request body from the resolved proposed content and an
|
|
89
|
-
* optional base. Mirrors the server's two payload shapes:
|
|
90
|
-
* - `baseContent` present → the server derives the unified diff + base hash
|
|
91
|
-
* (the diff-by-default path). A no-op (base === proposed) is rejected here
|
|
92
|
-
* rather than round-tripped to an EmptySuggestion 400.
|
|
93
|
-
* - no base → a `full-file` proposal; the server REQUIRES a `baseContentHash`,
|
|
94
|
-
* so we fingerprint the proposed content (a "here is my whole file" proposal
|
|
95
|
-
* with no base to diff against).
|
|
96
|
-
* An empty / whitespace-only note is dropped (kept absent, not blank).
|
|
97
|
-
*/
|
|
98
|
-
export function buildSuggestionCreateBody(input) {
|
|
99
|
-
const body = { proposedContent: input.proposedContent };
|
|
100
|
-
if (input.baseContent !== undefined) {
|
|
101
|
-
if (input.baseContent === input.proposedContent) {
|
|
102
|
-
throw new Error("No changes to propose — the working SKILL.md matches its base.");
|
|
103
|
-
}
|
|
104
|
-
body.baseContent = input.baseContent;
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
body.baseContentHash = sha256Hex(input.proposedContent);
|
|
108
|
-
}
|
|
109
|
-
const note = input.note?.trim();
|
|
110
|
-
if (note)
|
|
111
|
-
body.authorNote = note;
|
|
112
|
-
return body;
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* Map an hq-pro skill route error to a single user-facing line. Pure so the
|
|
116
|
-
* status → copy mapping is unit-tested independently of the network. Prefers the
|
|
117
|
-
* server's own `error` / `message` (they carry the actionable specifics — e.g.
|
|
118
|
-
* "Skill not found", "You need write access to this skill…").
|
|
119
|
-
*/
|
|
120
|
-
export function mapSkillError(status, body) {
|
|
121
|
-
const server = (typeof body.error === "string" && body.error) ||
|
|
122
|
-
(typeof body.message === "string" && body.message) ||
|
|
123
|
-
"";
|
|
124
|
-
if (status === 401)
|
|
125
|
-
return "Not authenticated — please run `hq login`";
|
|
126
|
-
if (status === 403)
|
|
127
|
-
return server || "Not authorized";
|
|
128
|
-
if (status === 404)
|
|
129
|
-
return server || "Not found";
|
|
130
|
-
if (status === 409)
|
|
131
|
-
return server || "Conflict — please retry";
|
|
132
|
-
if (status === 422)
|
|
133
|
-
return server || "Company vault not provisioned";
|
|
134
|
-
if (status >= 500)
|
|
135
|
-
return `Server error: ${server || status}`;
|
|
136
|
-
return server || `Request failed (${status})`;
|
|
137
|
-
}
|
|
138
|
-
function shortDate(iso) {
|
|
139
|
-
return typeof iso === "string" ? iso.slice(0, 10) : "";
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Render the review inbox as a table (one row per suggestion), optionally
|
|
143
|
-
* printing each suggestion's unified diff (or full proposed file, when the base
|
|
144
|
-
* drifted / the proposal is full-file) beneath its row. Pure → snapshot-testable.
|
|
145
|
-
*/
|
|
146
|
-
export function formatSuggestionsList(suggestions, opts = {}) {
|
|
147
|
-
if (suggestions.length === 0) {
|
|
148
|
-
return chalk.gray("No suggestions match — the review inbox is empty.");
|
|
149
|
-
}
|
|
150
|
-
const idW = Math.max(13, ...suggestions.map((s) => s.suggestionId.length));
|
|
151
|
-
const skillW = Math.max(8, ...suggestions.map((s) => s.skillUid.length));
|
|
152
|
-
const authW = Math.max(6, ...suggestions.map((s) => s.authorPersonUid.length));
|
|
153
|
-
const statusW = Math.max(6, ...suggestions.map((s) => s.status.length));
|
|
154
|
-
const lines = [];
|
|
155
|
-
lines.push(chalk.bold([
|
|
156
|
-
"SUGGESTION_ID".padEnd(idW),
|
|
157
|
-
"SKILL".padEnd(skillW),
|
|
158
|
-
"AUTHOR".padEnd(authW),
|
|
159
|
-
"STATUS".padEnd(statusW),
|
|
160
|
-
"BASE",
|
|
161
|
-
"CREATED",
|
|
162
|
-
].join(" ")));
|
|
163
|
-
for (const s of suggestions) {
|
|
164
|
-
lines.push([
|
|
165
|
-
s.suggestionId.padEnd(idW),
|
|
166
|
-
s.skillUid.padEnd(skillW),
|
|
167
|
-
s.authorPersonUid.padEnd(authW),
|
|
168
|
-
s.status.padEnd(statusW),
|
|
169
|
-
s.baseChanged ? chalk.yellow("changed") : "ok".padEnd(7),
|
|
170
|
-
shortDate(s.createdAt),
|
|
171
|
-
].join(" "));
|
|
172
|
-
if (s.authorNote) {
|
|
173
|
-
lines.push(chalk.dim(` note: ${s.authorNote}`));
|
|
174
|
-
}
|
|
175
|
-
if (opts.showDiff) {
|
|
176
|
-
const diff = s.presentation === "diff" && s.unifiedDiff
|
|
177
|
-
? s.unifiedDiff
|
|
178
|
-
: (s.fullContent ?? "");
|
|
179
|
-
const label = s.presentation === "diff" && s.unifiedDiff
|
|
180
|
-
? "diff"
|
|
181
|
-
: `full file${s.baseChanged ? " (base changed)" : ""}`;
|
|
182
|
-
lines.push(chalk.dim(` ── ${label} ──`));
|
|
183
|
-
for (const dl of diff.split("\n")) {
|
|
184
|
-
lines.push(colorizeDiffLine(` ${dl}`));
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return lines.join("\n");
|
|
189
50
|
}
|
|
190
|
-
/** Colour a unified-diff line (green add / red del / dim header). Pure. */
|
|
191
|
-
function colorizeDiffLine(line) {
|
|
192
|
-
const trimmed = line.trimStart();
|
|
193
|
-
if (trimmed.startsWith("+"))
|
|
194
|
-
return chalk.green(line);
|
|
195
|
-
if (trimmed.startsWith("-"))
|
|
196
|
-
return chalk.red(line);
|
|
197
|
-
if (trimmed.startsWith("@@"))
|
|
198
|
-
return chalk.cyan(line);
|
|
199
|
-
return chalk.dim(line);
|
|
200
|
-
}
|
|
201
|
-
// ---------------------------------------------------------------------------
|
|
202
|
-
// Company slug + local skill resolution
|
|
203
|
-
// ---------------------------------------------------------------------------
|
|
204
|
-
/** Read `.hq/config.json`'s `activeCompany` (mirrors signals/sources). */
|
|
205
51
|
export function readActiveCompanySlug(hqRoot) {
|
|
206
52
|
const configPath = path.join(hqRoot, ".hq", "config.json");
|
|
207
53
|
if (!fs.existsSync(configPath))
|
|
208
54
|
return undefined;
|
|
209
55
|
try {
|
|
210
|
-
const
|
|
211
|
-
return typeof
|
|
212
|
-
?
|
|
56
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
57
|
+
return typeof config.activeCompany === "string" && config.activeCompany.length > 0
|
|
58
|
+
? config.activeCompany
|
|
213
59
|
: undefined;
|
|
214
60
|
}
|
|
215
61
|
catch {
|
|
216
62
|
return undefined;
|
|
217
63
|
}
|
|
218
64
|
}
|
|
219
|
-
/**
|
|
220
|
-
* The skill routes are keyed on the company SLUG. Precedence: explicit
|
|
221
|
-
* `--company` → `.hq/config.json` activeCompany. Throws with actionable copy
|
|
222
|
-
* when neither is available.
|
|
223
|
-
*/
|
|
224
65
|
export function resolveCompanySlug(flag, hqRoot = DEFAULT_HQ_ROOT) {
|
|
225
66
|
const slug = flag ?? readActiveCompanySlug(hqRoot);
|
|
226
67
|
if (!slug) {
|
|
@@ -228,362 +69,214 @@ export function resolveCompanySlug(flag, hqRoot = DEFAULT_HQ_ROOT) {
|
|
|
228
69
|
}
|
|
229
70
|
return slug;
|
|
230
71
|
}
|
|
231
|
-
/**
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
export function findSkillFileByUid(roots, uid, maxDepth = 6) {
|
|
237
|
-
const SKIP = new Set([
|
|
238
|
-
"node_modules",
|
|
239
|
-
".git",
|
|
240
|
-
"dist",
|
|
241
|
-
"build",
|
|
242
|
-
".next",
|
|
243
|
-
"coverage",
|
|
244
|
-
]);
|
|
245
|
-
const queue = [];
|
|
246
|
-
for (const r of roots) {
|
|
247
|
-
if (fs.existsSync(r))
|
|
248
|
-
queue.push({ dir: r, depth: 0 });
|
|
249
|
-
}
|
|
250
|
-
while (queue.length > 0) {
|
|
251
|
-
const { dir, depth } = queue.shift();
|
|
252
|
-
let entries;
|
|
253
|
-
try {
|
|
254
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
255
|
-
}
|
|
256
|
-
catch {
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
for (const entry of entries) {
|
|
260
|
-
const full = path.join(dir, entry.name);
|
|
261
|
-
if (entry.isDirectory()) {
|
|
262
|
-
if (depth < maxDepth && !SKIP.has(entry.name)) {
|
|
263
|
-
queue.push({ dir: full, depth: depth + 1 });
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
else if (entry.isFile() && entry.name === "SKILL.md") {
|
|
267
|
-
let content;
|
|
268
|
-
try {
|
|
269
|
-
content = fs.readFileSync(full, "utf-8");
|
|
270
|
-
}
|
|
271
|
-
catch {
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
if (parseSkillUid(content) === uid)
|
|
275
|
-
return full;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
return null;
|
|
280
|
-
}
|
|
281
|
-
/**
|
|
282
|
-
* Resolve a `suggest` target (a `skl_…` uid OR a filesystem path) to the local
|
|
283
|
-
* SKILL.md, its uid, and its content. A uid is resolved by scanning the company
|
|
284
|
-
* skills dir and the cwd; a path is read directly (a directory → its SKILL.md),
|
|
285
|
-
* with the uid read from the file's frontmatter.
|
|
286
|
-
*/
|
|
287
|
-
export function resolveSkillTarget(target, deps) {
|
|
288
|
-
if (SKILL_UID_PATTERN.test(target)) {
|
|
289
|
-
const roots = Array.from(new Set([
|
|
290
|
-
path.join(deps.hqRoot, "companies", deps.companySlug, "skills"),
|
|
291
|
-
deps.cwd,
|
|
292
|
-
]));
|
|
293
|
-
const filePath = findSkillFileByUid(roots, target);
|
|
294
|
-
if (!filePath) {
|
|
295
|
-
throw new Error(`Could not find a local SKILL.md with skill_uid '${target}' under ${roots.join(", ")}. ` +
|
|
296
|
-
"Pass the path to the edited SKILL.md instead.");
|
|
297
|
-
}
|
|
298
|
-
return { filePath, skillUid: target, content: fs.readFileSync(filePath, "utf-8") };
|
|
299
|
-
}
|
|
300
|
-
// Filesystem path — accept either a SKILL.md file or a directory containing one.
|
|
301
|
-
let filePath = path.resolve(deps.cwd, target);
|
|
72
|
+
/** Resolve a UID directly, or read the immutable UID from a local SKILL.md. */
|
|
73
|
+
export function resolveSkillUid(target, cwd) {
|
|
74
|
+
if (SKILL_UID_PATTERN.test(target))
|
|
75
|
+
return target;
|
|
76
|
+
let filePath = path.resolve(cwd, target);
|
|
302
77
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
|
303
78
|
filePath = path.join(filePath, "SKILL.md");
|
|
304
79
|
}
|
|
305
80
|
if (!fs.existsSync(filePath)) {
|
|
306
|
-
throw new Error(`No SKILL.md found at '${target}'. Pass
|
|
81
|
+
throw new Error(`No SKILL.md found at '${target}'. Pass a SKILL.md path, its directory, or a skl_… uid.`);
|
|
307
82
|
}
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
throw new Error(`The SKILL.md at '${filePath}' has no skill_uid in its frontmatter — it is not registered yet. ` +
|
|
312
|
-
"Sync / reindex the skill first so it has a stable identity, then suggest against it.");
|
|
83
|
+
const uid = parseSkillUid(fs.readFileSync(filePath, "utf8"));
|
|
84
|
+
if (!uid) {
|
|
85
|
+
throw new Error(`The SKILL.md at '${filePath}' has no registered skill_uid. Register and stamp it with 'hq skill create <slug> --company <company>', then retry.`);
|
|
313
86
|
}
|
|
314
|
-
return
|
|
87
|
+
return uid;
|
|
315
88
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
* in a repo, or git is unavailable (the caller then falls back to full-file, or
|
|
320
|
-
* errors under `--diff`). Never throws.
|
|
321
|
-
*/
|
|
322
|
-
export async function readGitBase(filePath) {
|
|
323
|
-
try {
|
|
324
|
-
const git = simpleGit(path.dirname(filePath));
|
|
325
|
-
const top = (await git.revparse(["--show-toplevel"])).trim();
|
|
326
|
-
const rel = path.relative(top, filePath);
|
|
327
|
-
return await git.show([`HEAD:${rel}`]);
|
|
89
|
+
export function canonicalCompanySkillPath(hqRoot, companySlug, skillSlug) {
|
|
90
|
+
if (!COMPANY_SLUG_PATTERN.test(companySlug)) {
|
|
91
|
+
throw new Error("Company slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
|
|
328
92
|
}
|
|
329
|
-
|
|
330
|
-
|
|
93
|
+
if (!SKILL_SLUG_PATTERN.test(skillSlug)) {
|
|
94
|
+
throw new Error("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
|
|
331
95
|
}
|
|
96
|
+
return path.join(hqRoot, "companies", companySlug, "skills", skillSlug, "SKILL.md");
|
|
332
97
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
98
|
+
function displayNameFromSlug(slug) {
|
|
99
|
+
return slug
|
|
100
|
+
.split("-")
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
103
|
+
.join(" ");
|
|
338
104
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
105
|
+
export function makeSkillTemplate(input) {
|
|
106
|
+
const name = input.name?.trim() || displayNameFromSlug(input.slug);
|
|
107
|
+
const description = input.description?.trim() ?? "";
|
|
108
|
+
return [
|
|
109
|
+
"---",
|
|
110
|
+
`name: ${JSON.stringify(name)}`,
|
|
111
|
+
`description: ${JSON.stringify(description)}`,
|
|
112
|
+
"tags: []",
|
|
113
|
+
"---",
|
|
114
|
+
"",
|
|
115
|
+
`# ${name}`,
|
|
116
|
+
"",
|
|
117
|
+
"Describe when and how to use this skill.",
|
|
118
|
+
"",
|
|
119
|
+
].join("\n");
|
|
344
120
|
}
|
|
345
|
-
|
|
346
|
-
|
|
121
|
+
/** Replace SKILL.md without exposing a partially-written identity to agents. */
|
|
122
|
+
export function writeSkillFileAtomically(filePath, content) {
|
|
123
|
+
const dir = path.dirname(filePath);
|
|
124
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
125
|
+
const existingMode = fs.existsSync(filePath)
|
|
126
|
+
? fs.statSync(filePath).mode
|
|
127
|
+
: 0o644;
|
|
128
|
+
const tempPath = path.join(dir, `.SKILL.md.${process.pid}.${Date.now()}.tmp`);
|
|
129
|
+
try {
|
|
130
|
+
fs.writeFileSync(tempPath, content, {
|
|
131
|
+
encoding: "utf8",
|
|
132
|
+
flag: "wx",
|
|
133
|
+
mode: existingMode,
|
|
134
|
+
});
|
|
135
|
+
fs.renameSync(tempPath, filePath);
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
if (fs.existsSync(tempPath))
|
|
139
|
+
fs.rmSync(tempPath, { force: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function mapSkillError(status, body) {
|
|
143
|
+
const server = (typeof body.error === "string" && body.error) ||
|
|
144
|
+
(typeof body.message === "string" && body.message) ||
|
|
145
|
+
"";
|
|
146
|
+
if (status === 401)
|
|
147
|
+
return "Not authenticated — please run `hq login`";
|
|
148
|
+
if (status === 403)
|
|
149
|
+
return server || "Not authorized";
|
|
150
|
+
if (status === 404)
|
|
151
|
+
return server || "Skill not found";
|
|
152
|
+
if (status >= 500)
|
|
153
|
+
return `Server error: ${server || status}`;
|
|
154
|
+
return server || `Request failed (${status})`;
|
|
347
155
|
}
|
|
348
|
-
// ---------------------------------------------------------------------------
|
|
349
|
-
// Command registration
|
|
350
|
-
// ---------------------------------------------------------------------------
|
|
351
156
|
export function registerSkillCommand(program, deps = {}) {
|
|
352
|
-
const
|
|
157
|
+
const ensureToken = deps.ensureToken ?? ensureCognitoToken;
|
|
158
|
+
const apiFetch = deps.apiFetch ?? vaultApiFetch;
|
|
159
|
+
const cwd = deps.cwd ?? process.cwd;
|
|
160
|
+
const hqRoot = deps.hqRoot ?? DEFAULT_HQ_ROOT;
|
|
161
|
+
const syncFile = deps.syncFile ?? defaultSyncFile;
|
|
162
|
+
const reindexFn = deps.reindexFn ?? reindex;
|
|
353
163
|
const skill = program
|
|
354
164
|
.command("skill")
|
|
355
|
-
.description("
|
|
356
|
-
.option("--company <slug>", "Company slug (defaults to the active company)")
|
|
357
|
-
|
|
165
|
+
.description("Create company skills and discuss improvements")
|
|
166
|
+
.option("--company <slug>", "Company slug (defaults to the active company)")
|
|
167
|
+
.option("--hq-root <path>", "Local HQ root", hqRoot);
|
|
358
168
|
skill
|
|
359
|
-
.command("
|
|
360
|
-
.description("
|
|
361
|
-
.option("--
|
|
362
|
-
.option("--
|
|
363
|
-
.option("--
|
|
364
|
-
.action(async (
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
baseContent = fs.readFileSync(basePath, "utf-8");
|
|
384
|
-
baseSource = opts.base;
|
|
385
|
-
}
|
|
386
|
-
else {
|
|
387
|
-
const fromGit = await gitBase(resolved.filePath);
|
|
388
|
-
if (fromGit !== null) {
|
|
389
|
-
baseContent = fromGit;
|
|
390
|
-
baseSource = "git HEAD";
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
if (baseContent === undefined && opts.diff) {
|
|
394
|
-
console.error(chalk.red(`--diff needs a base, but none was found for ${resolved.filePath}. ` +
|
|
395
|
-
"Pass --base <file>, or commit a base version first."));
|
|
396
|
-
process.exit(1);
|
|
397
|
-
}
|
|
398
|
-
if (baseContent !== undefined && baseContent === resolved.content) {
|
|
399
|
-
console.error(chalk.red(`No changes to propose — the working SKILL.md matches its base (${baseSource}).`));
|
|
400
|
-
process.exit(1);
|
|
401
|
-
}
|
|
402
|
-
const body = buildSuggestionCreateBody({
|
|
403
|
-
proposedContent: resolved.content,
|
|
404
|
-
baseContent,
|
|
405
|
-
note: opts.note,
|
|
406
|
-
});
|
|
407
|
-
const token = await ensureCognitoToken();
|
|
408
|
-
const res = await vaultApiFetch({
|
|
409
|
-
token,
|
|
410
|
-
path: `${skillRoutePrefix(companySlug)}/${encodeURIComponent(resolved.skillUid)}/suggestions`,
|
|
411
|
-
method: "POST",
|
|
412
|
-
body: body,
|
|
169
|
+
.command("create <slug>")
|
|
170
|
+
.description("Register, stamp, reindex, and sync a company skill")
|
|
171
|
+
.option("--name <name>", "Display name when creating a new template")
|
|
172
|
+
.option("--description <text>", "Description when creating a new template")
|
|
173
|
+
.option("--no-sync", "Register locally without uploading the stamped file")
|
|
174
|
+
.action(async (slug, opts) => {
|
|
175
|
+
if (!SKILL_SLUG_PATTERN.test(slug)) {
|
|
176
|
+
throw new Error("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
|
|
177
|
+
}
|
|
178
|
+
const parentOpts = skill.opts();
|
|
179
|
+
const resolvedRoot = path.resolve(parentOpts.hqRoot ?? hqRoot);
|
|
180
|
+
const companySlug = resolveCompanySlug(parentOpts.company, resolvedRoot);
|
|
181
|
+
const filePath = canonicalCompanySkillPath(resolvedRoot, companySlug, slug);
|
|
182
|
+
if (fs.existsSync(filePath) && !fs.statSync(filePath).isFile()) {
|
|
183
|
+
throw new Error(`Expected a SKILL.md file at '${filePath}'.`);
|
|
184
|
+
}
|
|
185
|
+
const localContent = fs.existsSync(filePath)
|
|
186
|
+
? fs.readFileSync(filePath, "utf8")
|
|
187
|
+
: makeSkillTemplate({
|
|
188
|
+
slug,
|
|
189
|
+
...(opts.name !== undefined ? { name: opts.name } : {}),
|
|
190
|
+
...(opts.description !== undefined
|
|
191
|
+
? { description: opts.description }
|
|
192
|
+
: {}),
|
|
413
193
|
});
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
194
|
+
const token = await ensureToken();
|
|
195
|
+
const response = await apiFetch({
|
|
196
|
+
token,
|
|
197
|
+
path: `/v1/files/skills/company/${encodeURIComponent(companySlug)}/register`,
|
|
198
|
+
method: "POST",
|
|
199
|
+
body: {
|
|
200
|
+
path: `skills/${slug}/SKILL.md`,
|
|
201
|
+
content: localContent,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
const body = (await response.json().catch(() => ({})));
|
|
206
|
+
throw new Error(mapSkillError(response.status, body));
|
|
426
207
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
208
|
+
const registered = (await response.json());
|
|
209
|
+
if (typeof registered.skillUid !== "string" ||
|
|
210
|
+
!SKILL_UID_PATTERN.test(registered.skillUid) ||
|
|
211
|
+
typeof registered.content !== "string" ||
|
|
212
|
+
parseSkillUid(registered.content) !== registered.skillUid) {
|
|
213
|
+
throw new Error("The server returned an invalid skill registration response; the local file was not changed.");
|
|
430
214
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
skill
|
|
434
|
-
.command("list-suggestions")
|
|
435
|
-
.description("List the review inbox — open suggestions on skills you own (or one skill via --skill)")
|
|
436
|
-
.option("--skill <skillUid>", "Scope to one skill (skl_…); requires write access on it")
|
|
437
|
-
.option("--status <status>", "open | accepted | declined | all (default: open)")
|
|
438
|
-
.option("--show-diff", "Print each suggestion's unified diff / proposed file")
|
|
439
|
-
.option("--json", "Emit raw JSON instead of a table")
|
|
440
|
-
.action(async (opts) => {
|
|
215
|
+
writeSkillFileAtomically(filePath, registered.content);
|
|
216
|
+
let reindexStatus = null;
|
|
441
217
|
try {
|
|
442
|
-
|
|
443
|
-
const status = (opts.status ?? "open");
|
|
444
|
-
if (!VALID_LIST_STATUSES.includes(status)) {
|
|
445
|
-
console.error(chalk.red(`Invalid --status '${opts.status}': must be one of ${VALID_LIST_STATUSES.join(", ")}`));
|
|
446
|
-
process.exit(1);
|
|
447
|
-
}
|
|
448
|
-
if (opts.skill !== undefined && !SKILL_UID_PATTERN.test(opts.skill)) {
|
|
449
|
-
console.error(chalk.red(`Invalid --skill '${opts.skill}': must be a skl_… uid`));
|
|
450
|
-
process.exit(1);
|
|
451
|
-
}
|
|
452
|
-
const body = { status };
|
|
453
|
-
if (opts.skill !== undefined)
|
|
454
|
-
body.skillUid = opts.skill;
|
|
455
|
-
const token = await ensureCognitoToken();
|
|
456
|
-
const res = await vaultApiFetch({
|
|
457
|
-
token,
|
|
458
|
-
path: `${skillRoutePrefix(companySlug)}/suggestions/list`,
|
|
459
|
-
method: "POST",
|
|
460
|
-
body,
|
|
461
|
-
});
|
|
462
|
-
if (!res.ok)
|
|
463
|
-
await failFromResponse(res);
|
|
464
|
-
const data = (await res.json());
|
|
465
|
-
if (opts.json) {
|
|
466
|
-
console.log(JSON.stringify(data, null, 2));
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
console.log(formatSuggestionsList(data.suggestions, { showDiff: opts.showDiff }));
|
|
218
|
+
reindexStatus = reindexFn({ repoRoot: resolvedRoot }).status;
|
|
470
219
|
}
|
|
471
220
|
catch (err) {
|
|
472
|
-
console.
|
|
473
|
-
process.exit(1);
|
|
221
|
+
console.warn(chalk.yellow(`⚠ Skill registered, but HQ reindex failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
474
222
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
.
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
.action(async (suggestionId, opts) => {
|
|
486
|
-
try {
|
|
487
|
-
if (!SUGGESTION_ID_PATTERN.test(suggestionId)) {
|
|
488
|
-
console.error(chalk.red(`Invalid suggestion id '${suggestionId}': must be a sgn_… id`));
|
|
489
|
-
process.exit(1);
|
|
490
|
-
}
|
|
491
|
-
const accept = opts.accept === true;
|
|
492
|
-
const decline = opts.decline === true;
|
|
493
|
-
if (accept === decline) {
|
|
494
|
-
console.error(chalk.red("Pass exactly one of --accept or --decline."));
|
|
495
|
-
process.exit(1);
|
|
496
|
-
}
|
|
497
|
-
if (opts.skill !== undefined && !SKILL_UID_PATTERN.test(opts.skill)) {
|
|
498
|
-
console.error(chalk.red(`Invalid --skill '${opts.skill}': must be a skl_… uid`));
|
|
499
|
-
process.exit(1);
|
|
500
|
-
}
|
|
501
|
-
const companySlug = resolveCompanySlug(skill.opts().company);
|
|
502
|
-
const token = await ensureCognitoToken();
|
|
503
|
-
// Resolve the suggestion's skillUid + live path from the SAME LIST route
|
|
504
|
-
// the console uses — accept/decline are path↔uid guarded, so the path
|
|
505
|
-
// must come from the authoritative row, never guessed. `all` so an
|
|
506
|
-
// already-resolved suggestion yields a precise "already <status>" error.
|
|
507
|
-
const listBody = { status: "all" };
|
|
508
|
-
if (opts.skill !== undefined)
|
|
509
|
-
listBody.skillUid = opts.skill;
|
|
510
|
-
const listRes = await vaultApiFetch({
|
|
511
|
-
token,
|
|
512
|
-
path: `${skillRoutePrefix(companySlug)}/suggestions/list`,
|
|
513
|
-
method: "POST",
|
|
514
|
-
body: listBody,
|
|
515
|
-
});
|
|
516
|
-
if (!listRes.ok)
|
|
517
|
-
await failFromResponse(listRes);
|
|
518
|
-
const { suggestions } = (await listRes.json());
|
|
519
|
-
const row = suggestions.find((s) => s.suggestionId === suggestionId);
|
|
520
|
-
if (!row) {
|
|
521
|
-
console.error(chalk.red(`Suggestion ${suggestionId} is not in your review inbox. ` +
|
|
522
|
-
"If it targets a skill you don't own, scope the lookup with --skill <skl_…>."));
|
|
523
|
-
process.exit(1);
|
|
524
|
-
}
|
|
525
|
-
if (row.status !== "open") {
|
|
526
|
-
console.error(chalk.yellow(`Suggestion ${suggestionId} is already ${row.status} — nothing to do.`));
|
|
527
|
-
process.exit(1);
|
|
528
|
-
}
|
|
529
|
-
if (accept) {
|
|
530
|
-
if (!row.path) {
|
|
531
|
-
console.error(chalk.red("Could not resolve the live skill path for this suggestion — the skill may have been removed."));
|
|
532
|
-
process.exit(1);
|
|
533
|
-
}
|
|
534
|
-
const acceptBody = { path: row.path };
|
|
535
|
-
if (opts.acknowledgeBaseChanged)
|
|
536
|
-
acceptBody.acknowledgeBaseChanged = true;
|
|
537
|
-
const res = await vaultApiFetch({
|
|
223
|
+
if (reindexStatus !== null && reindexStatus !== 0) {
|
|
224
|
+
console.warn(chalk.yellow(`⚠ Skill registered, but HQ reindex exited ${reindexStatus}. Run 'hq reindex --repo-root ${resolvedRoot}' to retry discovery.`));
|
|
225
|
+
}
|
|
226
|
+
if (opts.sync !== false) {
|
|
227
|
+
let syncResult;
|
|
228
|
+
try {
|
|
229
|
+
syncResult = await syncFile({
|
|
230
|
+
filePath,
|
|
231
|
+
companySlug,
|
|
232
|
+
hqRoot: resolvedRoot,
|
|
538
233
|
token,
|
|
539
|
-
path: `${skillRoutePrefix(companySlug)}/${encodeURIComponent(row.skillUid)}/suggestions/${encodeURIComponent(suggestionId)}/accept`,
|
|
540
|
-
method: "POST",
|
|
541
|
-
body: acceptBody,
|
|
542
234
|
});
|
|
543
|
-
if (!res.ok) {
|
|
544
|
-
const errBody = await readJsonBody(res);
|
|
545
|
-
// AC4 (US-009): the base drifted — guide the operator to re-run
|
|
546
|
-
// with acknowledgement rather than silently overwriting.
|
|
547
|
-
if (res.status === 409 &&
|
|
548
|
-
errBody.code === "WRITE_SKILL_SUGGESTION_BASE_CHANGED") {
|
|
549
|
-
console.error(chalk.yellow("The skill changed since this suggestion was written. Re-run with " +
|
|
550
|
-
"--acknowledge-base-changed to overwrite it with the full proposed file."));
|
|
551
|
-
process.exit(1);
|
|
552
|
-
}
|
|
553
|
-
console.error(chalk.red(mapSkillError(res.status, errBody)));
|
|
554
|
-
process.exit(1);
|
|
555
|
-
}
|
|
556
|
-
const data = (await res.json());
|
|
557
|
-
console.log(chalk.green("Suggestion accepted — merged and closed."));
|
|
558
|
-
console.log(` Skill: ${data.skillUid}`);
|
|
559
|
-
console.log(` Suggestion: ${data.suggestionId}`);
|
|
560
|
-
console.log(` Accepted by: ${data.acceptedBy}`);
|
|
561
|
-
return;
|
|
562
235
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
path: `${skillRoutePrefix(companySlug)}/${encodeURIComponent(row.skillUid)}/suggestions/${encodeURIComponent(suggestionId)}/decline`,
|
|
570
|
-
method: "POST",
|
|
571
|
-
body: declineBody,
|
|
572
|
-
});
|
|
573
|
-
if (!res.ok)
|
|
574
|
-
await failFromResponse(res);
|
|
575
|
-
const data = (await res.json());
|
|
576
|
-
console.log(chalk.green("Suggestion declined — the skill was left unchanged."));
|
|
577
|
-
console.log(` Skill: ${data.skillUid}`);
|
|
578
|
-
console.log(` Suggestion: ${data.suggestionId}`);
|
|
579
|
-
console.log(` Declined by: ${data.declinedBy}`);
|
|
580
|
-
if (data.reason)
|
|
581
|
-
console.log(` Reason: ${data.reason}`);
|
|
236
|
+
catch (err) {
|
|
237
|
+
throw new Error(`Skill ${registered.skillUid} is stamped locally at '${filePath}', but sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
238
|
+
}
|
|
239
|
+
if (syncResult.aborted) {
|
|
240
|
+
throw new Error(`Skill ${registered.skillUid} is stamped locally at '${filePath}', but sync aborted because the remote file conflicts.`);
|
|
241
|
+
}
|
|
582
242
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
243
|
+
console.log(chalk.green(`Skill ready: ${registered.skillUid}`));
|
|
244
|
+
console.log(` File: ${filePath}`);
|
|
245
|
+
console.log(` Discovery: ${reindexStatus === 0 ? "reindexed" : "reindex needs attention"}`);
|
|
246
|
+
console.log(registered.accessPolicy === "open"
|
|
247
|
+
? ` Access: Open — every active ${companySlug} member can edit`
|
|
248
|
+
: " Access: preserved existing policy");
|
|
249
|
+
console.log(` Sync: ${opts.sync === false ? "not requested" : "complete"}`);
|
|
250
|
+
});
|
|
251
|
+
skill
|
|
252
|
+
.command("propose <target>")
|
|
253
|
+
.description("Post a comment-only improvement for a skill")
|
|
254
|
+
.requiredOption("-m, --message <text>", "The improvement to discuss")
|
|
255
|
+
.action(async (target, opts) => {
|
|
256
|
+
const message = opts.message.trim();
|
|
257
|
+
if (!message)
|
|
258
|
+
throw new Error("An improvement message is required.");
|
|
259
|
+
const parentOpts = skill.opts();
|
|
260
|
+
const resolvedRoot = path.resolve(parentOpts.hqRoot ?? hqRoot);
|
|
261
|
+
const companySlug = resolveCompanySlug(parentOpts.company, resolvedRoot);
|
|
262
|
+
const skillUid = resolveSkillUid(target, cwd());
|
|
263
|
+
const token = await ensureToken();
|
|
264
|
+
const response = await apiFetch({
|
|
265
|
+
token,
|
|
266
|
+
path: `/v1/files/skills/company/${encodeURIComponent(companySlug)}/${encodeURIComponent(skillUid)}/comments`,
|
|
267
|
+
method: "POST",
|
|
268
|
+
body: { anchor: { kind: "whole-skill" }, body: message },
|
|
269
|
+
});
|
|
270
|
+
if (!response.ok) {
|
|
271
|
+
const body = (await response.json().catch(() => ({})));
|
|
272
|
+
throw new Error(mapSkillError(response.status, body));
|
|
586
273
|
}
|
|
274
|
+
const posted = (await response.json());
|
|
275
|
+
console.log(chalk.green(`Improvement posted: ${posted.commentId}`));
|
|
276
|
+
console.log(` Skill: ${posted.skillUid}`);
|
|
277
|
+
console.log(` Author: ${posted.authorPersonUid}`);
|
|
278
|
+
console.log(` Message: ${posted.body}`);
|
|
279
|
+
console.log(chalk.dim(" Review it in HQ Console → Skills → Improvements"));
|
|
587
280
|
});
|
|
588
281
|
return skill;
|
|
589
282
|
}
|
package/dist/main.js
CHANGED
|
@@ -72,6 +72,7 @@ import { isExpectedUserError } from "./utils/expected-cli-error.js";
|
|
|
72
72
|
import { isEpipe } from "./utils/epipe.js";
|
|
73
73
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
74
74
|
import { isAuthError } from "./utils/auth-error.js";
|
|
75
|
+
import { browserLoginAbandonedMessage } from "./utils/browser-login-abandoned.js";
|
|
75
76
|
import { isCompanySelectionError } from "./utils/company-selection-error.js";
|
|
76
77
|
import { canOfferTeamUpgrade, formatPlanGateError, isPlanGateError, offerTeamUpgrade, } from "./utils/plan-gate-error.js";
|
|
77
78
|
import { upgradeToTeam } from "./utils/team-upgrade.js";
|
|
@@ -206,9 +207,8 @@ registerGroupGrantsCommand(program);
|
|
|
206
207
|
// browse-vs-sync subcommands (`hq files browse`/`cat`) onto the same group.
|
|
207
208
|
const filesCmd = registerFilesCommand(program);
|
|
208
209
|
registerFilesBrowseCommands(filesCmd);
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
// routes the MCP (US-007) and console merge (US-009) surfaces use — no forked logic.
|
|
210
|
+
// Comment-only skill improvement loop. Structured suggestion/review commands are
|
|
211
|
+
// intentionally absent; live content changes remain governed by FILE_ACL sync.
|
|
212
212
|
registerSkillCommand(program);
|
|
213
213
|
// Membership management (subcommand group — hq members invite|list|revoke)
|
|
214
214
|
registerMembersCommand(program);
|
|
@@ -395,6 +395,21 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
395
395
|
deps.stderr.write(`hq: ${err.message}\n`);
|
|
396
396
|
deps.setExitCode(1);
|
|
397
397
|
}
|
|
398
|
+
else if (browserLoginAbandonedMessage(err)) {
|
|
399
|
+
// HQ-CLI-V: the caller triggered the implicit browser sign-in fallback
|
|
400
|
+
// (an expiring/absent HQ session) and then never completed it —
|
|
401
|
+
// @indigoai-us/hq-cloud's browserLogin rejected either at its 15-minute
|
|
402
|
+
// deadline or because the user declined at the IdP (access_denied). A
|
|
403
|
+
// human walking away from an OAuth prompt is expected auth state the user
|
|
404
|
+
// fixes with `hq login`, not an hq-cli defect. Print the fixed actionable
|
|
405
|
+
// remedy and skip Sentry so one abandoned login doesn't file a permanent
|
|
406
|
+
// high-priority "crash". Placed with the auth carve-out (both mean "your
|
|
407
|
+
// HQ session isn't usable; run hq login"); every genuine browser-login or
|
|
408
|
+
// token-exchange fault stays outside the closed allowlist and still
|
|
409
|
+
// captures below.
|
|
410
|
+
deps.stderr.write(`hq: ${browserLoginAbandonedMessage(err)}\n`);
|
|
411
|
+
deps.setExitCode(1);
|
|
412
|
+
}
|
|
398
413
|
else if (isPlanGateError(err)) {
|
|
399
414
|
// hq-pro's plan denials are expected product limits, never a CLI crash.
|
|
400
415
|
// The shared vault client has already decoded and typed the small safe
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* If `err` is an abandoned implicit browser sign-in, return a short, fixed
|
|
3
|
+
* user-facing remedy; otherwise return `null`.
|
|
4
|
+
*
|
|
5
|
+
* A non-null result means the caller should print the message and SKIP Sentry
|
|
6
|
+
* capture — a user who walked away from an OAuth prompt is not an hq-cli defect.
|
|
7
|
+
* A null result means "handle as usual (capture to Sentry)". CognitoRefreshError
|
|
8
|
+
* is a refresh fault, never an abandonment, so it is excluded structurally even
|
|
9
|
+
* though it extends CognitoAuthError.
|
|
10
|
+
*/
|
|
11
|
+
export declare function browserLoginAbandonedMessage(err: unknown): string | null;
|
|
12
|
+
//# sourceMappingURL=browser-login-abandoned.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// src/utils/browser-login-abandoned.ts
|
|
2
|
+
//
|
|
3
|
+
// Classify an ABANDONED implicit browser sign-in — the caller triggered the
|
|
4
|
+
// fallback OAuth flow (an expiring/absent HQ session on any of the ~100
|
|
5
|
+
// ensureCognitoToken/ensureCognitoIdToken call sites) and then never completed
|
|
6
|
+
// it. @indigoai-us/hq-cloud's browserLogin arms a 15-minute deadline and also
|
|
7
|
+
// rejects when the identity provider reports the user declined; both mint a
|
|
8
|
+
// bare CognitoAuthError. A human walking away from an OAuth prompt is expected
|
|
9
|
+
// auth state the user fixes with `hq login`, NOT an hq-cli defect — so the
|
|
10
|
+
// top-level handler prints an actionable line and exits non-zero but SKIPS
|
|
11
|
+
// Sentry capture, mirroring the auth (HQ-CLI-8), company-selection (HQ-CLI-7),
|
|
12
|
+
// expected-user-error (HQ-CLI-6), and environmental-FS (HQ-CLI-2) carve-outs.
|
|
13
|
+
//
|
|
14
|
+
// HQ-CLI-V (Sentry indigo-d0/hq-cli 7685055900): `hq integrations list
|
|
15
|
+
// --company <slug> --json` tried to refresh an expiring session, the refresh
|
|
16
|
+
// 400'd (invalid_grant), and it fell back to browserLogin(). Nobody finished
|
|
17
|
+
// the sign-in, so exactly 15 minutes later browserLogin rejected with
|
|
18
|
+
// `CognitoAuthError("Login timed out after 15 minutes")`. That error carries no
|
|
19
|
+
// `code`, no `expected` flag, and is not an AuthError/CompanySelectionError/…,
|
|
20
|
+
// so every predicate in handleTopLevelError returned false and the abandoned
|
|
21
|
+
// login was captured as a high-priority, unfixable "crash".
|
|
22
|
+
//
|
|
23
|
+
// The allowlist is CLOSED at the two upstream-minted human-abandonment
|
|
24
|
+
// signatures and defaults to null (capture). Every other CognitoAuthError — a
|
|
25
|
+
// state-parameter mismatch (possible CSRF), a missing callback code, a
|
|
26
|
+
// non-access_denied IdP error, a token-exchange failure — and every
|
|
27
|
+
// CognitoRefreshError (a refresh fault, not an abandonment) still reaches
|
|
28
|
+
// Sentry.
|
|
29
|
+
import { CognitoAuthError, CognitoRefreshError } from "@indigoai-us/hq-cloud";
|
|
30
|
+
/**
|
|
31
|
+
* Fixed, actionable remedy. Deliberately interpolates NO upstream error text,
|
|
32
|
+
* so this carve-out can never widen disclosure and needs no redaction pass.
|
|
33
|
+
*/
|
|
34
|
+
const BROWSER_LOGIN_ABANDONED_MESSAGE = "Browser sign-in was not completed. Run `hq login`, finish the sign-in in " +
|
|
35
|
+
"your browser, then re-run your command.";
|
|
36
|
+
/**
|
|
37
|
+
* The 15-minute login-deadline signature (@indigoai-us/hq-cloud cognito-auth
|
|
38
|
+
* `waitForAuthCode`). The minute count is tolerated (`\d+`) so an upstream
|
|
39
|
+
* retune of the deadline turns the contract test red rather than silently
|
|
40
|
+
* restoring the Sentry noise.
|
|
41
|
+
*/
|
|
42
|
+
const LOGIN_TIMED_OUT = /^Login timed out after \d+ minutes?$/;
|
|
43
|
+
/**
|
|
44
|
+
* The user declined at the identity provider. Matched EXACTLY — other
|
|
45
|
+
* `Cognito returned error: <x>` values (server_error, …) are not abandonment
|
|
46
|
+
* and stay reportable.
|
|
47
|
+
*/
|
|
48
|
+
const ACCESS_DENIED = "Cognito returned error: access_denied";
|
|
49
|
+
/**
|
|
50
|
+
* If `err` is an abandoned implicit browser sign-in, return a short, fixed
|
|
51
|
+
* user-facing remedy; otherwise return `null`.
|
|
52
|
+
*
|
|
53
|
+
* A non-null result means the caller should print the message and SKIP Sentry
|
|
54
|
+
* capture — a user who walked away from an OAuth prompt is not an hq-cli defect.
|
|
55
|
+
* A null result means "handle as usual (capture to Sentry)". CognitoRefreshError
|
|
56
|
+
* is a refresh fault, never an abandonment, so it is excluded structurally even
|
|
57
|
+
* though it extends CognitoAuthError.
|
|
58
|
+
*/
|
|
59
|
+
export function browserLoginAbandonedMessage(err) {
|
|
60
|
+
if (!(err instanceof CognitoAuthError))
|
|
61
|
+
return null;
|
|
62
|
+
if (err instanceof CognitoRefreshError)
|
|
63
|
+
return null;
|
|
64
|
+
const { message } = err;
|
|
65
|
+
if (LOGIN_TIMED_OUT.test(message) || message === ACCESS_DENIED) {
|
|
66
|
+
return BROWSER_LOGIN_ABANDONED_MESSAGE;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=browser-login-abandoned.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.24",
|
|
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.71",
|
|
34
34
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
35
35
|
"@sentry/node": "^10.49.0",
|
|
36
36
|
"@tobilu/qmd": "2.5.3",
|