@hasna/skills 0.4.0 → 0.5.1

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.
Files changed (48) hide show
  1. package/README.md +220 -5
  2. package/bin/index.js +7747 -5645
  3. package/bin/mcp.js +1493 -431
  4. package/bin/migrate.js +148 -40
  5. package/bin/server.js +53 -83
  6. package/bin/worker.js +41 -73
  7. package/dist/admin-contract.d.ts +37 -19
  8. package/dist/admin-contract.js +1 -1
  9. package/dist/cli/cli.test-utils.d.ts +10 -8
  10. package/dist/cli/commands/customer-profile.d.ts +2 -0
  11. package/dist/cli/commands/customer-verification.d.ts +5 -0
  12. package/dist/cli/commands/tool-primitives.d.ts +1 -1
  13. package/dist/cli/commands/workspace-member-mutations.d.ts +2 -0
  14. package/dist/cli/commands/workspace-members.d.ts +2 -0
  15. package/dist/cli/commands/workspace-selection.d.ts +11 -0
  16. package/dist/cli/env-assignment.d.ts +9 -0
  17. package/dist/index.d.ts +8 -2
  18. package/dist/index.js +841 -167
  19. package/dist/lib/agent-sync.d.ts +13 -8
  20. package/dist/lib/api-url.d.ts +4 -3
  21. package/dist/lib/app-home.d.ts +0 -1
  22. package/dist/lib/client-types.d.ts +75 -0
  23. package/dist/lib/credential-state.d.ts +12 -0
  24. package/dist/lib/fleet-credentials.d.ts +41 -15
  25. package/dist/lib/home-adoption.d.ts +2 -0
  26. package/dist/lib/home-census.d.ts +3 -1
  27. package/dist/lib/local-opt-in.d.ts +24 -0
  28. package/dist/lib/portable-skills-files.d.ts +6 -2
  29. package/dist/lib/read-access.d.ts +83 -0
  30. package/dist/lib/remote-auth.d.ts +23 -3
  31. package/dist/lib/remote-client.d.ts +45 -5
  32. package/dist/lib/remote-profile.d.ts +26 -0
  33. package/dist/lib/remote-registry.d.ts +7 -3
  34. package/dist/lib/remote-workspace-selection.d.ts +58 -0
  35. package/dist/lib/remote-workspace.d.ts +76 -0
  36. package/dist/lib/skillinfo.d.ts +1 -1
  37. package/dist/lib/workspace-profile.d.ts +49 -0
  38. package/dist/mcp/helpers.d.ts +22 -0
  39. package/dist/mcp/index.d.ts +16 -0
  40. package/dist/sdk/governance-store.d.ts +1 -0
  41. package/dist/sdk/index.d.ts +8 -2
  42. package/dist/sdk/index.js +1312 -297
  43. package/dist/sdk/outputs.d.ts +0 -11
  44. package/dist/sdk/runs.d.ts +1 -1
  45. package/dist/storage.js +6 -40
  46. package/docs/skill-standard.md +30 -2
  47. package/package.json +6 -4
  48. package/dist/lib/instance-credentials-race.fixture.d.ts +0 -1
@@ -29,9 +29,9 @@ export declare const SYNC_AGENTS: readonly SyncAgent[];
29
29
  */
30
30
  export declare const SKILLS_SOURCE_ENV = "SKILLS_SOURCE";
31
31
  /**
32
- * Ownership marker written beside every SKILL.md this tool syncs. Its presence is how a
33
- * re-sync tells "a skill I wrote, safe to update" from "a skill the user hand-authored,
34
- * do not touch". A hidden sidecar rather than a frontmatter field so the SKILL.md the
32
+ * Ownership marker written beside every SKILL.md this tool syncs. Its exact managedBy
33
+ * value tells a re-sync which directories this tool owns; a foreign or malformed
34
+ * marker grants no authority. A hidden sidecar rather than a frontmatter field so the SKILL.md the
35
35
  * agent loads stays exactly the adapted document and nothing else.
36
36
  */
37
37
  export declare const SYNC_MARKER_FILE = ".hasna-skills.json";
@@ -42,6 +42,10 @@ export interface SyncMarker {
42
42
  source: string;
43
43
  syncedAt: string;
44
44
  }
45
+ /** Internal shared ownership predicate; not exported by the public package entrypoint. */
46
+ export declare function isSkillsOwnershipMarker(marker: unknown): marker is Record<string, unknown>;
47
+ /** Read only a regular ownership sidecar. */
48
+ export declare function hasSkillsOwnershipMarker(dir: string): boolean;
45
49
  export declare function isSyncAgent(value: string): value is SyncAgent;
46
50
  export declare function resolveSyncAgents(arg?: string): SyncAgent[];
47
51
  /** The global skills directory for an agent, honouring a test-supplied home. */
@@ -142,12 +146,12 @@ export interface WriteManagedAgentSkillParams {
142
146
  /**
143
147
  * Write one skill into one agent's global folder, non-clobbering.
144
148
  *
145
- * A directory this tool has written before carries the marker file and is replaced with
149
+ * A directory this tool has written before carries our exact ownership marker and is replaced with
146
150
  * an exact mirror — except that a managed home holding full content is never silently
147
151
  * replaced with an executable pointer stub (that would be data loss; it is refused
148
- * unless `force` is passed). A directory with a SKILL.md but no marker is the user's
149
- * own skill and is skipped unless `force` explicitly adopts it. Any other pre-existing
150
- * unmarked directory is always left untouched. A fresh directory is created.
152
+ * unless `force` is passed). A directory with a SKILL.md but no valid Skills marker is
153
+ * skipped unless `force` explicitly adopts it. Any other pre-existing unmanaged
154
+ * directory is always left untouched. A fresh directory is created.
151
155
  */
152
156
  export declare function writeManagedAgentSkill(params: WriteManagedAgentSkillParams): AgentSyncAction;
153
157
  export interface ManagedDirWriteResult {
@@ -173,6 +177,7 @@ export interface ManagedDirWriteOptions {
173
177
  export declare function writeManagedSkillDir(dir: string, skillMd: string, options: ManagedDirWriteOptions): ManagedDirWriteResult;
174
178
  /**
175
179
  * Remove a skill this tool synced from an agent folder. Refuses to delete a directory it
176
- * did not write (no marker), so it can never remove a user's hand-authored skill.
180
+ * did not write (no valid Skills ownership marker). Foreign or malformed markers
181
+ * grant no deletion authority, and removal has no force override.
177
182
  */
178
183
  export declare function removeManagedAgentSkill(skill: string, agent: SyncAgent, homeDir?: string): boolean;
@@ -5,9 +5,10 @@
5
5
  * thin reading of `@hasna/contracts/client`. It exists only so the two failure
6
6
  * MODES stay named:
7
7
  *
8
- * - Read paths fail closed: `resolveApiUrl()` returns `undefined` when nothing
9
- * is configured, and the caller keeps working against the bundled local
10
- * registry.
8
+ * - Read paths fail closed: `resolveApiUrl()` returns `undefined` only under
9
+ * the explicit local opt-in (`HASNA_SKILLS_LOCAL=1`, alias `SKILLS_LOCAL=1`),
10
+ * and the caller keeps working against the bundled local registry. Without
11
+ * the opt-in, an install with nothing configured is a refusal, not a URL.
11
12
  * - Auth and write paths fail loudly: `requireApiUrl()` throws an error naming
12
13
  * the missing configuration.
13
14
  *
@@ -17,7 +17,6 @@
17
17
  * Nothing moves on disk in this phase — the package just resolves the new
18
18
  * paths.
19
19
  */
20
- export type PathKind = "config" | "data" | "state" | "cache";
21
20
  export interface PathsResolverOptions {
22
21
  app: string;
23
22
  internal?: boolean;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The @hasna/contracts client types that cross this package's published
3
+ * boundary, spelled locally.
4
+ *
5
+ * WHY THIS FILE EXISTS. `bun build --target bun` inlines @hasna/contracts into
6
+ * every shipped bundle, so the runtime never needs it installed alongside
7
+ * @hasna/skills and it is correctly a build-time devDependency. `tsc
8
+ * --emitDeclarationOnly` inlines nothing: the moment a PUBLIC declaration names
9
+ * a type from `@hasna/contracts/client`, the published `.d.ts` imports it, and
10
+ * a consumer who installed only this package's declared runtime dependencies
11
+ * fails type-checking (7 x TS2307 before #1782 fixed the same shape in
12
+ * @hasna/secrets).
13
+ *
14
+ * This file is NOT the vendored resolver returning: it has no imports, no
15
+ * runtime statement, no tier, no Keychain read, no URL ladder — only the
16
+ * shapes, copied from the @hasna/contracts/@1.0.2 declarations. Its sibling
17
+ * `client-types.test.ts` asserts each one is mutually assignable with the real
18
+ * declaration, in the direction it actually crosses, so a drifted shape fails
19
+ * `tsc` in the same build step that emits the declarations it protects.
20
+ *
21
+ * `src/lib/fleet-credentials.ts` keeps importing the resolver and its VALUE
22
+ * exports from @hasna/contracts/client (inlined at runtime); only the types it
23
+ * re-exports or names in its own public signatures come from here.
24
+ *
25
+ * Do not add an import. Do not add a value. This file must stay a leaf.
26
+ */
27
+ /** Which link of the chain supplied the credential. */
28
+ export type CredentialTier = "argument" | "override" | "pointer" | "profile" | "keychain" | "disk" | "env";
29
+ export interface ResolvedCredential {
30
+ /** The secret. Property access and destructuring work; it is never enumerated or serialized. */
31
+ readonly apiKey: string;
32
+ readonly tier: CredentialTier;
33
+ /** Where it came from: an env key NAME, an absolute file path, or a Keychain item reference. Never a value. */
34
+ readonly source: string;
35
+ /** True for tiers an operator sets on purpose. These never fall through. */
36
+ readonly deliberate: boolean;
37
+ /** When tier === "pointer", the vault ITEM KEY to resolve at request time. Never a credential value. */
38
+ readonly pointerVaultKey?: string;
39
+ /** The disk paths consulted before this credential was chosen. */
40
+ readonly diskCandidates: readonly string[];
41
+ /** Human-readable advisory. Never contains key material. */
42
+ readonly warning: string | null;
43
+ }
44
+ /** The captured outcome of one `security` invocation. `stdout` IS the secret; it is never logged. */
45
+ export interface KeychainCommandResult {
46
+ /** Exit status; null when the tool could not be started or was killed. */
47
+ status: number | null;
48
+ stdout: string;
49
+ stderr: string;
50
+ }
51
+ /** Runs `/usr/bin/security` with the given argv — no shell. Injected by tests. */
52
+ export type KeychainCommandRunner = (argv: readonly string[]) => KeychainCommandResult;
53
+ /** Tier 3 controls. Every field is optional; production callers pass nothing. */
54
+ export interface KeychainTierOptions {
55
+ /**
56
+ * Whether the Keychain is consulted for a caller-built env object. The tier
57
+ * is AMBIENT: by default it runs only for the live `process.env`. Injecting
58
+ * `run` implies `true`.
59
+ */
60
+ enabled?: boolean;
61
+ /** Defaults to `process.platform`; the tier exists only on `"darwin"`. */
62
+ platform?: string;
63
+ /** The machine's host name, used as the account when `HASNA_STATION` is unset. */
64
+ hostname?: () => string;
65
+ /** The `security` runner. Defaults to spawning `/usr/bin/security` by argv. */
66
+ run?: KeychainCommandRunner;
67
+ }
68
+ export interface CredentialChainOptions {
69
+ /** Tier 1: an explicit key, e.g. from `--api-key`. */
70
+ apiKey?: string;
71
+ /** Tier 1: an explicit profile name, e.g. from `--profile`. Beats `HASNA_PROFILE`. */
72
+ profile?: string;
73
+ /** Tier 3: Keychain controls — a fake `security` runner in tests, an opt-out on CI. */
74
+ keychain?: KeychainTierOptions;
75
+ }
@@ -0,0 +1,12 @@
1
+ export interface CredentialState {
2
+ /** `misconfigured` is the ladder's refusal carried as data; `error` says why. */
3
+ mode: "hosted" | "local" | "misconfigured";
4
+ apiUrl: string | null;
5
+ apiUrlSource: string | null;
6
+ apiKeySource: string | null;
7
+ apiKeyTier: string | null;
8
+ credentialsFile: string | null;
9
+ credentialsFileMode: string | null;
10
+ error: string | null;
11
+ }
12
+ export declare function describeCredentialState(): CredentialState;
@@ -36,8 +36,14 @@
36
36
  * are not read anywhere else in this package. `SKILL_API_KEY` (singular) is
37
37
  * gone: it shadowed nothing canonical and was never documented.
38
38
  *
39
- * THREE OUTCOMES, and no fourth:
39
+ * THIRD: the local run is opt-in only (owner ruling 2026-09-04, hasna/apps#1720;
40
+ * class-patch order 2026-09-06). `HASNA_SKILLS_LOCAL=1` (alias `SKILLS_LOCAL=1`)
41
+ * selects the on-machine run when the ENVIRONMENT configures no authority; it is
42
+ * answered before the resolver runs, so opting in never reads the Keychain or
43
+ * the credentials file. A configured environment always outranks it. The
44
+ * outcomes, and no fourth:
40
45
  *
46
+ * - the local opt-in selected → LOCAL, announced once on stderr.
41
47
  * - a credential resolves → HOSTED. The authority is the configured
42
48
  * URL, else the fleet gateway. A credential
43
49
  * that cannot produce a usable key — a
@@ -51,14 +57,18 @@
51
57
  * There is no local fallback here: serving
52
58
  * local results while authentication is
53
59
  * unconfigured is a false green.
54
- * - neither a credential nor a URL LOCAL. Skills is an OSS tool with a
55
- * bundled corpus, so running on this
56
- * machine is a real mode — and it says so,
57
- * once, on stderr.
60
+ * - neither a credential nor a URL, and no opt-in
61
+ * LOUD failure, exit non-zero, no SQLite,
62
+ * no *-local-fallback event. Running on
63
+ * this machine is a deliberate choice now,
64
+ * not the silence that follows a missing
65
+ * credential.
58
66
  */
59
- import { type CredentialChainOptions, type CredentialTier, type KeychainTierOptions, type ResolvedCredential } from "@hasna/contracts/client";
67
+ import type { CredentialChainOptions, CredentialTier, KeychainTierOptions, ResolvedCredential } from "./client-types.js";
60
68
  /** The app slug: the Keychain service, the `~/.hasna/<app>` folder, the gateway path. */
61
69
  export declare const SKILLS_APP = "skills";
70
+ /** The deliberate unhosted opt-in env names. Re-exported for the surfaces that have to name them. */
71
+ export { SKILLS_LOCAL_OPT_IN_ENV_KEYS, isSkillsLocalOptIn, selectsSkillsLocalMode } from "./local-opt-in.js";
62
72
  type Env = Record<string, string | undefined>;
63
73
  /** `HASNA_SKILLS_API_URL`, then the accepted `SKILLS_API_URL` alias. */
64
74
  export declare const SKILLS_API_URL_ENV_KEYS: readonly string[];
@@ -129,6 +139,14 @@ export declare class SkillsFleetCredentialError extends Error {
129
139
  readonly code: SkillsFleetErrorCode;
130
140
  constructor(message: string, code?: SkillsFleetErrorCode);
131
141
  }
142
+ /**
143
+ * True for this package's own refusal, across bundle boundaries.
144
+ *
145
+ * Exported for the surfaces that turn the refusal into data (an MCP tool's
146
+ * `AUTH_REQUIRED` result, a CLI handler's one-line stderr exit) so they match
147
+ * on the error's NAME rather than on a class identity a bundle may not share.
148
+ */
149
+ export declare function isSkillsFleetCredentialError(error: unknown): error is SkillsFleetCredentialError;
132
150
  /**
133
151
  * Normalize a configured Skills authority to the origin the client dials.
134
152
  *
@@ -164,10 +182,12 @@ export declare function skillsCredentialFilePath(env?: Env): string;
164
182
  /**
165
183
  * Say — once per process, on stderr — that this install is running locally.
166
184
  *
167
- * Local mode is legitimate for Skills: the corpus ships in the package. It is
168
- * still announced, because "no credential resolved" and "deliberately offline"
169
- * look identical in the output otherwise, and the first one is usually a
170
- * misconfiguration the operator wants to hear about.
185
+ * Local mode is legitimate for Skills: the corpus ships in the package. It is a
186
+ * deliberate choice now, though: it is reachable only through the explicit
187
+ * opt-in (`HASNA_SKILLS_LOCAL=1`), and it is still announced, because "no
188
+ * credential resolved" and "deliberately offline" look identical in the output
189
+ * otherwise, and the first one is usually a misconfiguration the operator wants
190
+ * to hear about.
171
191
  */
172
192
  export declare function noticeLocalSkillsMode(write?: (line: string) => void): void;
173
193
  /** Test seam: forget that the local-mode line was printed. */
@@ -203,10 +223,11 @@ export declare function requireSkillsApiKey(action?: string, env?: Env, options?
203
223
  * The credential for a surface that reports refusals as data (an MCP tool, a
204
224
  * `--json` command) rather than as an exception.
205
225
  *
206
- * `reason` is the ladder's own message when an authority is configured and no
207
- * key resolved a refusal carried as a value, NOT a fallback: the caller must
208
- * still refuse. It is null only when nothing at all is configured, which is the
209
- * ordinary "not signed in" case.
226
+ * `reason` is the ladder's own message when a hosted resolution is refused an
227
+ * authority with no key, or an unconfigured install without the local opt-in
228
+ * (fail-closed ruling) a refusal carried as a value, NOT a fallback: the
229
+ * caller must still refuse. It is null only when the explicit local opt-in
230
+ * selected the on-machine run, the ordinary "not signed in" case.
210
231
  */
211
232
  export declare function skillsCredentialOrReason(env?: Env, options?: SkillsFleetOptions): Promise<{
212
233
  apiKey: string;
@@ -225,6 +246,12 @@ export declare function skillsCredentialOrReason(env?: Env, options?: SkillsFlee
225
246
  * Keychain, credentials file), else the authority a resolved credential implies.
226
247
  * With neither, this returns null and the caller fails loudly: R1 still holds,
227
248
  * an install that named no service does not get to send an email address to one.
249
+ *
250
+ * The fail-closed refusal an unconfigured install now raises on every DATA
251
+ * surface is swallowed here and reported as plain "no authority": for a flow
252
+ * whose purpose is to acquire a credential, "nothing is configured" and "local
253
+ * mode is opted in" have the same two-step way out (configure an origin, then
254
+ * sign in).
228
255
  */
229
256
  export declare function resolveSkillsApiOrigin(env?: Env, options?: SkillsFleetOptions): {
230
257
  origin: string;
@@ -249,4 +276,3 @@ export declare class MissingSkillsFleetError extends Error {
249
276
  readonly code = "MISSING_API_URL";
250
277
  constructor(action?: string);
251
278
  }
252
- export {};
@@ -30,6 +30,8 @@ export interface AdoptionScan {
30
30
  }
31
31
  export interface AdoptionOptions extends PortableSkillOptions {
32
32
  agents?: SyncAgent[];
33
+ /** Optional selected home/corpus names; omitted means all names. */
34
+ names?: string[];
33
35
  /** Write markers and the conflicts ledger. Without it, scan only. */
34
36
  apply?: boolean;
35
37
  }
@@ -23,4 +23,6 @@ export interface DriftCensus {
23
23
  managed: number;
24
24
  clean: boolean;
25
25
  }
26
- export declare function censusHomeDrift(options?: AdoptionOptions): DriftCensus;
26
+ export declare function censusHomeDrift(options?: AdoptionOptions & {
27
+ names?: string[];
28
+ }): DriftCensus;
@@ -0,0 +1,24 @@
1
+ /** The deliberate unhosted opt-in, canonical name first. */
2
+ export declare const SKILLS_LOCAL_OPT_IN_ENV_KEYS: readonly ["HASNA_SKILLS_LOCAL", "SKILLS_LOCAL"];
3
+ export type SkillsLocalOptInEnv = Record<string, string | undefined>;
4
+ /** True when the operator deliberately asked for the unhosted on-machine run. */
5
+ export declare function isSkillsLocalOptIn(env?: SkillsLocalOptInEnv): boolean;
6
+ /** Every env name that can configure a Skills authority or credential, resolver-derived. */
7
+ export declare function skillsAuthorityEnvKeys(): string[];
8
+ /**
9
+ * Does the ENVIRONMENT itself configure a Skills authority or credential?
10
+ *
11
+ * Deliberately narrower than "does a credential resolve": answering it must not
12
+ * touch the Keychain or the filesystem, because doing so would defeat the
13
+ * isolation the opt-in short-circuit exists to provide. It reads the env
14
+ * dictionary and nothing else.
15
+ *
16
+ * A DECLARED-BUT-BLANK variable counts as absent HERE — a blank has always been
17
+ * this package's spelling for "not configured", and helpers in the wild blank
18
+ * rather than delete. It is NOT absent once we do go hosted: the resolver
19
+ * refuses a blank loudly rather than falling through to another identity, which
20
+ * is the behaviour that matters at that point.
21
+ */
22
+ export declare function hasSkillsEnvAuthorityIntent(env?: SkillsLocalOptInEnv): boolean;
23
+ /** True when this environment should be served by the on-box local run. */
24
+ export declare function selectsSkillsLocalMode(env?: SkillsLocalOptInEnv): boolean;
@@ -9,7 +9,11 @@ export declare function defaultRuntimeContract(entrypoint?: string): PortableSki
9
9
  /** Provenance defaults; content_hash is filled at write time over the bundle. */
10
10
  export declare function defaultProvenance(sourceCommit?: string): PortableSkillProvenance;
11
11
  export declare function normalizePortableSkillName(name: string): string;
12
+ /** New local identities use hyphens; legacy lookups and command names keep their grammar. */
13
+ export declare function normalizeNewPortableSkillName(name: string): string;
12
14
  export declare function readPortableSkillManifest(skillPath: string, fallbackName?: string): PortableSkillManifest;
15
+ /** Import needs the original spelling before the legacy reader discards case boundaries. */
16
+ export declare function readPortableSkillManifestForImport(skillPath: string): PortableSkillManifest;
13
17
  export declare function parseSkillKind(value: string | undefined): SkillKind | undefined;
14
18
  /**
15
19
  * The version a skill EXPLICITLY declares, or undefined when none of the sources do:
@@ -46,8 +50,8 @@ export declare function ensurePortableSkillFiles(skillPath: string, manifest: Po
46
50
  /**
47
51
  * Instruction (prose) skills are consumed by agent renderers/MCP docs, not run
48
52
  * locally, so `port` must never fabricate executable stubs (package.json, bin,
49
- * src/index.ts, tsconfig.json, AGENTS.md). It keeps the copied SKILL.md verbatim
50
- * and writes a minimal skill.json declaring `kind: "instruction"`.
53
+ * src/index.ts, tsconfig.json, AGENTS.md). Apart from aligning declared names
54
+ * after an import rename, it preserves the copied prose and metadata.
51
55
  */
52
56
  export declare function ensureInstructionSkillFiles(skillPath: string, manifest: PortableSkillManifest): PortableSkillManifest;
53
57
  export declare function copySkillDirectory(source: string, destination: string): void;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The gate every DATA surface runs before it answers from the bundled corpus,
3
+ * and the one registry those surfaces show.
4
+ *
5
+ * `skills list` has resolved through the fleet ladder since the fail-closed
6
+ * ruling (owner directive 2026-09-04, hasna/apps#1720): with no credential, no
7
+ * authority and no `HASNA_SKILLS_LOCAL=1` opt-in it exits 1 naming the refusal,
8
+ * opens nothing and serves nothing. `skills info` / `docs` / `requires`, the
9
+ * bare non-TTY listing and the MCP discovery tools (`list_skills`,
10
+ * `search_skills`, `get_skill_info`, `get_skill_docs`, `list_categories`,
11
+ * `list_tags`, `get_requirements`) did NOT — they read `loadRegistry()` and
12
+ * answered from the bundled catalog plus `~/.hasna/skills/installed` at exit 0
13
+ * with no notice, on the very machine `skills list` had just refused (#1720
14
+ * validation, round 1). Two surfaces that disagree about whether an install is
15
+ * configured is the false green the ruling removed, so they now share this one
16
+ * gate with the browsing commands.
17
+ *
18
+ * - refused (nothing configured, no opt-in; an authority with no key; a
19
+ * deliberate selection that cannot be honoured) → `SkillsFleetCredentialError`
20
+ * from the shared ladder, carried to the caller unchanged: the CLI prints
21
+ * its one line and exits 1, an MCP tool answers `AUTH_REQUIRED`.
22
+ * - the explicit local opt-in → `{ mode: "local" }`; the ladder has announced
23
+ * local mode once on stderr, and the caller serves the on-machine answer.
24
+ * - a credential resolves → `{ mode: "hosted", apiOrigin }`. The key is
25
+ * deliberately NOT returned here: a surface that only reads local data has
26
+ * no business holding it, and the surfaces that send it resolve it again
27
+ * at request time (`remoteRequestHeaders`, per call, completing a vault
28
+ * pointer if that is the tier).
29
+ *
30
+ * Resolved fresh on every call — the resolver contract — so a credential that
31
+ * appears, rotates or disappears mid-process is honoured by the next call.
32
+ */
33
+ import { type SkillsFleetOptions } from "./fleet-credentials.js";
34
+ import { type SkillMeta } from "./registry.js";
35
+ type Env = Record<string, string | undefined>;
36
+ /** Where a read surface stands, once the ladder has let it through. Never carries a key. */
37
+ export type SkillsReadAccess = {
38
+ mode: "hosted";
39
+ apiOrigin: string;
40
+ } | {
41
+ mode: "local";
42
+ };
43
+ /**
44
+ * Run the fail-closed routing preamble for a read surface.
45
+ *
46
+ * Throws {@link SkillsFleetCredentialError} when the ladder refuses; the
47
+ * caller must let that refusal out (as a non-zero exit or a structured error),
48
+ * never swallow it into a local answer.
49
+ */
50
+ export declare function requireSkillsReadAccess(env?: Env, options?: SkillsFleetOptions): Promise<SkillsReadAccess>;
51
+ /**
52
+ * The registry a browsing surface should show — the CLI's `list` / `search` /
53
+ * `categories` / `tags` and the MCP discovery tools, from ONE implementation.
54
+ *
55
+ * The default read path is folder UNION cloud: whenever the install is pointed
56
+ * at a hosted instance (a resolved credential, and HASNA_SKILLS_API_URL for your
57
+ * own instance) the authenticated remote registry joins the local listing even
58
+ * without `--remote`. The explicit local opt-in keeps today's exact local
59
+ * output; an unconfigured or auth-missing install is a refusal, thrown from the
60
+ * shared ladder (fail-closed R1 — see mergeRemoteRegistry()).
61
+ *
62
+ * `--remote` used to REPLACE the local registry: `skills list --remote` returned exactly
63
+ * what the instance served and nothing else, so the bundled corpus and every skill the
64
+ * operator had written locally disappeared from the listing the moment they pointed the
65
+ * CLI at their own server. It now MERGES, under the precedence documented in
66
+ * src/lib/registry-merge.ts: custom > extension > local > remote > official, "whichever
67
+ * copy this machine would actually use wins the listing".
68
+ *
69
+ * The profile (`all` vs the curated basic set) applies to the local half only. The
70
+ * instance's skills are never filtered by it: the basic profile is a hand-written list of
71
+ * ten bundled names, so applying it to remote entries would drop every published skill
72
+ * from `skills list --remote` - the same disappearance this change exists to fix.
73
+ *
74
+ * A remote failure is still fatal. An explicit `--remote` request (and a configured,
75
+ * authenticated default read) that fails surfaces a clear error, and silently returning
76
+ * the local half of a merge the user asked to include the remote half in would report
77
+ * success for a listing that is missing entries.
78
+ */
79
+ export declare function getBrowseRegistry(options?: {
80
+ all?: boolean;
81
+ remote?: boolean;
82
+ }): Promise<SkillMeta[]>;
83
+ export {};
@@ -1,3 +1,7 @@
1
+ import { type RemoteWorkspaceContext, type RemoteWorkspaceSession, type RemoteAccountWorkspaceDiscovery } from "./remote-workspace-selection.js";
2
+ import { type RemoteWorkspaceMembersOptions } from "./remote-workspace.js";
3
+ import { type SetRemoteWorkspaceMemberRole, type RemoveRemoteWorkspaceMember } from "./remote-workspace.js";
4
+ import { type UpdateRemoteProfile, type UpdateRemoteWorkspace } from "./remote-profile.js";
1
5
  export declare class HostedApiError extends Error {
2
6
  readonly status?: number;
3
7
  readonly code?: string;
@@ -21,12 +25,28 @@ export declare class RemoteSkillsAuthClient {
21
25
  startDevice(): Promise<any>;
22
26
  pollDevice(deviceCode: string): Promise<any>;
23
27
  private sessionClient;
24
- createApiKey(email: string, code: string, name: string, scopes?: string[]): Promise<{
28
+ /** Discover memberships with fresh sign-in. No key or session is saved. */
29
+ listAccountWorkspaces(email: string, code: string, expectedUserId?: string): Promise<RemoteAccountWorkspaceDiscovery>;
30
+ /** Contains a secret session token. Selection never creates or stores an API key. */
31
+ switchWorkspace(email: string, code: string, context: RemoteWorkspaceContext): Promise<RemoteWorkspaceSession>;
32
+ private workspaceLogin;
33
+ createApiKey(email: string, code: string, name: string, scopes?: string[], context?: RemoteWorkspaceContext): Promise<{
25
34
  [field: string]: unknown;
26
35
  key: string;
27
36
  }>;
28
- listApiKeys(email: string, code: string): Promise<Record<string, unknown>[]>;
29
- revokeApiKey(email: string, code: string, keyId: string): Promise<Record<string, unknown>>;
37
+ listApiKeys(email: string, code: string, context?: RemoteWorkspaceContext): Promise<Record<string, unknown>[]>;
38
+ revokeApiKey(email: string, code: string, keyId: string, context?: RemoteWorkspaceContext): Promise<Record<string, unknown>>;
39
+ /** Reauthentication is ephemeral: it never replaces a saved key or profile. */
40
+ updateProfile(email: string, code: string, input: UpdateRemoteProfile, context?: RemoteWorkspaceContext): Promise<{
41
+ user: import("./remote-profile.js").RemoteCustomerProfile;
42
+ }>;
43
+ updateCurrentWorkspace(email: string, code: string, input: UpdateRemoteWorkspace, context?: RemoteWorkspaceContext): Promise<{
44
+ organization: import("./remote-profile.js").RemoteCurrentWorkspace;
45
+ }>;
46
+ /** Fresh owner/admin session; explicit context survives default-workspace OTP selection. */
47
+ listWorkspaceMembers(email: string, code: string, options?: RemoteWorkspaceMembersOptions, context?: RemoteWorkspaceContext): Promise<import("./remote-workspace.js").RemoteWorkspaceMembersPage>;
48
+ setWorkspaceMemberRole(email: string, code: string, membershipId: string, input: SetRemoteWorkspaceMemberRole, context?: RemoteWorkspaceContext): Promise<import("./remote-workspace.js").RemoteWorkspaceMemberRoleResult>;
49
+ removeWorkspaceMember(email: string, code: string, membershipId: string, input: RemoveRemoteWorkspaceMember, context?: RemoteWorkspaceContext): Promise<import("./remote-workspace.js").RemoteWorkspaceMemberRemovalResult>;
30
50
  /** Common auth transport used by CLI login, preserving the selected instance through awaits. */
31
51
  request(path: string, options?: RequestInit): Promise<any>;
32
52
  }
@@ -1,6 +1,10 @@
1
+ import { type RemoteWorkspaceContext, type RemoteAccountWorkspaces, type RemoteWorkspaceSession, type RemoteWorkspaceSelectionErrorCode } from "./remote-workspace-selection.js";
2
+ import { type RemoteWorkspaceMembersOptions, type RemoteWorkspaceMembersPage } from "./remote-workspace.js";
3
+ import { type RemoteWorkspaceMemberErrorCode, type SetRemoteWorkspaceMemberRole, type RemoveRemoteWorkspaceMember, type RemoteWorkspaceMemberRoleResult, type RemoteWorkspaceMemberRemovalResult } from "./remote-workspace.js";
1
4
  import { type RemoteSkillRunContract } from "./remote-run-contract.js";
2
5
  import { type RemoteCreditPack, type RemoteRunApproval, type RemoteRunQuote } from "./remote-account.js";
3
6
  import { type RemoteInputFile } from "./remote-files.js";
7
+ import { type UpdateRemoteProfile, type UpdateRemoteWorkspace } from "./remote-profile.js";
4
8
  /**
5
9
  * A server that predates this client's pin/tag/incremental-sync routes answered
6
10
  * 404/405 for them. The caller must never mistake that for "no pins" or "empty
@@ -17,7 +21,22 @@ export declare class RemoteRouteUnsupportedError extends Error {
17
21
  export declare class RemoteRequestError extends Error {
18
22
  readonly path: string;
19
23
  readonly status: number;
20
- constructor(path: string, status: number, statusText: string);
24
+ constructor(path: string, status: number, _statusText?: string);
25
+ }
26
+ /** A recognized membership refusal, with fixed text and no server payload. */
27
+ export declare class RemoteWorkspaceMemberError extends RemoteRequestError {
28
+ readonly code: RemoteWorkspaceMemberErrorCode;
29
+ constructor(path: string, code: RemoteWorkspaceMemberErrorCode);
30
+ }
31
+ /** Fixed text for recognized workspace refusals; no reflected server error payload. */
32
+ export declare class RemoteWorkspaceSelectionError extends RemoteRequestError {
33
+ readonly code: RemoteWorkspaceSelectionErrorCode;
34
+ constructor(path: string, code: RemoteWorkspaceSelectionErrorCode);
35
+ }
36
+ /** A recognized unavailable capability; all displayed text is client-owned. */
37
+ export declare class RemoteCapabilityUnavailableError extends RemoteRequestError {
38
+ readonly code: "SUBSCRIPTION_CHECKOUT_UNAVAILABLE";
39
+ constructor();
21
40
  }
22
41
  /**
23
42
  * A remote pin on a skill, matching the hosted-pins wire shape
@@ -106,6 +125,26 @@ export declare class RemoteSkillsClient {
106
125
  /** Quote first and fail closed when the caller has not approved the required credits. */
107
126
  submitQuotedRun(slug: string, input?: Record<string, unknown>, args?: string[], approval?: RemoteRunApproval): Promise<RemoteSkillRunContract>;
108
127
  getIdentity(): Promise<Record<string, unknown>>;
128
+ /** List memberships with the current interactive session; never writes credentials. */
129
+ listAccountWorkspaces(expectedUserId?: string): Promise<RemoteAccountWorkspaces>;
130
+ /** Return a new ephemeral session; this client and any saved key/profile stay unchanged. */
131
+ switchWorkspace(context: RemoteWorkspaceContext): Promise<RemoteWorkspaceSession>;
132
+ private requestWorkspaceSelection;
133
+ /** Requires a customer session; API keys and support impersonation cannot edit names. */
134
+ updateProfile(input: UpdateRemoteProfile): Promise<{
135
+ user: import("./remote-profile.js").RemoteCustomerProfile;
136
+ }>;
137
+ /** Owner/admin session only; the current workspace identity and slug stay fixed. */
138
+ updateCurrentWorkspace(input: UpdateRemoteWorkspace): Promise<{
139
+ organization: import("./remote-profile.js").RemoteCurrentWorkspace;
140
+ }>;
141
+ /** Current owner/admin customer session only; the server refuses API keys and impersonation. */
142
+ listWorkspaceMembers(options?: RemoteWorkspaceMembersOptions): Promise<RemoteWorkspaceMembersPage>;
143
+ /** Exact incarnation and expected role; no refresh or retry. Server enforces current authority. */
144
+ setWorkspaceMemberRole(membershipId: string, input: SetRemoteWorkspaceMemberRole): Promise<RemoteWorkspaceMemberRoleResult>;
145
+ /** Removes only this incarnation. A successful tombstone replay is returned unchanged. */
146
+ removeWorkspaceMember(membershipId: string, input: RemoveRemoteWorkspaceMember): Promise<RemoteWorkspaceMemberRemovalResult>;
147
+ private requestWorkspaceMember;
109
148
  listApiKeys(): Promise<Record<string, unknown>[]>;
110
149
  createApiKey(name: string, scopes?: string[]): Promise<{
111
150
  key: string;
@@ -211,11 +250,12 @@ export declare class RemoteSkillsClient {
211
250
  }
212
251
  /**
213
252
  * The client for the configured instance, or null when this install runs on
214
- * this machine (no credential and no authority).
253
+ * this machine which is now the explicit local opt-in only
254
+ * (`HASNA_SKILLS_LOCAL=1`); with no credential, no authority and no opt-in the
255
+ * shared ladder throws (fail-closed ruling), so the caller fails loudly instead
256
+ * of quietly reading the bundled corpus while authentication is unconfigured.
215
257
  *
216
- * A configured authority with no credential does NOT return null: the shared
217
- * ladder throws, so the caller fails loudly instead of quietly reading the
218
- * bundled corpus while authentication is unconfigured.
258
+ * A configured authority with no credential also throws for the same reason.
219
259
  *
220
260
  * ASYNC because the credential ladder is: a vault pointer
221
261
  * (`HASNA_SKILLS_API_KEY_REF`) is completed through the secrets vault before a
@@ -0,0 +1,26 @@
1
+ export type RemoteCustomerRole = "owner" | "admin" | "member" | "viewer";
2
+ export type RemoteCustomerProfile = {
3
+ id: string;
4
+ email: string;
5
+ displayName: string | null;
6
+ role: RemoteCustomerRole;
7
+ };
8
+ export type RemoteCurrentWorkspace = {
9
+ id: string;
10
+ slug: string;
11
+ name: string;
12
+ };
13
+ export type UpdateRemoteProfile = {
14
+ displayName: string;
15
+ };
16
+ export type UpdateRemoteWorkspace = {
17
+ name: string;
18
+ };
19
+ /** Client input checks do not grant authority; the selected server owns policy. */
20
+ export declare function customerNamePatch(input: unknown, field: "displayName" | "name"): Record<string, string>;
21
+ export declare function parseUpdatedProfile(value: unknown): {
22
+ user: RemoteCustomerProfile;
23
+ };
24
+ export declare function parseUpdatedWorkspace(value: unknown): {
25
+ organization: RemoteCurrentWorkspace;
26
+ };
@@ -45,9 +45,13 @@ export declare function loadRemoteRegistry(options?: RemoteRegistryOptions): Pro
45
45
  * origin sees the folder UNION cloud in the plain `list`/`search` path, while
46
46
  * every other install keeps today's exact local behavior.
47
47
  *
48
- * - Nothing configured (no credential, no authority) -> the local list is
49
- * returned unchanged and no request is attempted. An install running on
50
- * this machine must stay byte-identical to the pre-merge output.
48
+ * - Nothing configured, local opted in -> the local list is returned
49
+ * unchanged and no request is attempted. An install running on this
50
+ * machine must stay byte-identical to the pre-merge output.
51
+ * - Nothing configured and NO local opt-in -> this throws, from the shared
52
+ * ladder (MISSING_API_CREDENTIAL, naming `HASNA_SKILLS_LOCAL` as the
53
+ * deliberate way out): local mode is opt-in only, and an unconfigured
54
+ * install is a refusal rather than a silent local listing.
51
55
  * - An authority configured with NO credential -> this throws, from the
52
56
  * shared ladder. It used to return the local half silently, which is the
53
57
  * false green the 2026-09-04 ruling removes: an operator who pointed this