@hasna/skills 0.1.63 → 0.1.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -6
- package/bin/index.js +1593 -228
- package/bin/mcp.js +290 -24
- package/bin/migrate.js +229 -33
- package/bin/server.js +774 -116
- package/bin/worker.js +558 -79
- package/dist/cli/commands/registry-reconcile.d.ts +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +559 -251
- package/dist/lib/agent-sync.d.ts +26 -6
- package/dist/lib/auth-store.d.ts +37 -0
- package/dist/lib/config.d.ts +51 -0
- package/dist/lib/home-census.d.ts +4 -0
- package/dist/lib/home-migration.d.ts +8 -9
- package/dist/lib/native-storage.d.ts +1 -1
- package/dist/lib/portable-skills.d.ts +35 -6
- package/dist/lib/pull.d.ts +31 -0
- package/dist/lib/registry-reconcile.d.ts +114 -0
- package/dist/lib/registry.d.ts +7 -4
- package/dist/lib/remote-client.d.ts +118 -1
- package/dist/lib/remote-registry.d.ts +26 -0
- package/dist/lib/revision.d.ts +29 -0
- package/dist/sdk/index.js +1351 -353
- package/dist/server/app.d.ts +1 -1
- package/dist/server/config.d.ts +12 -2
- package/dist/server/rows.d.ts +2 -1
- package/dist/server/skills-api.d.ts +108 -6
- package/dist/server/sqlite-store.d.ts +20 -3
- package/dist/server/store.d.ts +31 -5
- package/dist/server/types.d.ts +98 -3
- package/dist/storage.js +7 -2
- package/migrations/postgres/0004_hosted_pins.sql +28 -0
- package/migrations/postgres/0005_revision_tombstone_registry.sql +37 -0
- package/migrations/postgres/0005_tag_projection.sql +36 -0
- package/migrations/sqlite/0004_hosted_pins.sql +21 -0
- package/migrations/sqlite/0005_revision_tombstone_registry.sql +18 -0
- package/migrations/sqlite/0005_tag_projection.sql +25 -0
- package/package.json +3 -2
package/dist/lib/agent-sync.d.ts
CHANGED
|
@@ -56,6 +56,18 @@ export declare function agentGlobalSkillsDir(agent: SyncAgent, homeDir?: string)
|
|
|
56
56
|
* would corrupt meaning, and corpus/instruction skills are authored tool-neutral.
|
|
57
57
|
*/
|
|
58
58
|
export declare function adaptSkillMdForAgent(skillMd: string, agent: string): string;
|
|
59
|
+
/**
|
|
60
|
+
* The sentence every sync pointer stub carries in its body. It survives per-agent
|
|
61
|
+
* adaptation (adaptSkillMdForAgent only touches `user_invocable`), so it is the stable
|
|
62
|
+
* fingerprint for "this document is a pointer, not the skill's real content".
|
|
63
|
+
*/
|
|
64
|
+
export declare const POINTER_MARKER_PHRASE = "This is an executable skill from the @hasna/skills catalog";
|
|
65
|
+
/**
|
|
66
|
+
* True when a SKILL.md is a sync pointer stub: frontmatter declares `kind: executable`
|
|
67
|
+
* AND the body carries the canonical pointer sentence. A full content document is never
|
|
68
|
+
* a stub even when it mentions the sentence, because it lacks the kind marker.
|
|
69
|
+
*/
|
|
70
|
+
export declare function isPointerSkillMd(markdown: string): boolean;
|
|
59
71
|
/** A pointer SKILL.md for an executable skill: what it is and how to actually run it. */
|
|
60
72
|
export declare function pointerSkillMd(name: string, description: string): string;
|
|
61
73
|
export type SyncActionKind = "create" | "update" | "skip";
|
|
@@ -81,8 +93,12 @@ export interface SyncSkillsOptions {
|
|
|
81
93
|
rootDir?: string;
|
|
82
94
|
/**
|
|
83
95
|
* Explicit canonical-corpus source: a directory of skill folders, or the monorepo
|
|
84
|
-
* package root (which contains `skills/`
|
|
96
|
+
* package root (which contains `skills/`). Takes precedence over
|
|
85
97
|
* $SKILLS_SOURCE, which takes precedence over the installed corpus cache.
|
|
98
|
+
* Agent-workflow skills are NOT part of the public repo corpus anymore — they
|
|
99
|
+
* moved to the private per-station store (hasna-internal/fleet-resources) and
|
|
100
|
+
* reach sync through the installed cache — so a package root resolves to
|
|
101
|
+
* `skills/` only.
|
|
86
102
|
*/
|
|
87
103
|
sourceDir?: string;
|
|
88
104
|
/** Home directory override for agent skill dirs. Tests only. */
|
|
@@ -96,11 +112,13 @@ export interface SyncSkillsResult {
|
|
|
96
112
|
*
|
|
97
113
|
* The package ships no bundled corpus. Precedence is explicit-over-ambient:
|
|
98
114
|
* 1. `options.sourceDir` - an explicit source: a corpus dir, or a package root
|
|
99
|
-
* containing `skills/`
|
|
115
|
+
* containing `skills/`
|
|
100
116
|
* 2. `$SKILLS_SOURCE` - the ambient spelling of the same thing
|
|
101
117
|
* 3. the installed cache - `getPortableSkillsRoot()` (what `skills pull` writes),
|
|
102
118
|
* resolved through `resolveCorpusRoot` so a migrated owner layout
|
|
103
|
-
* (~/.hasna/skills/skills/) is read in preference to `installed/`.
|
|
119
|
+
* (~/.hasna/skills/skills/) is read in preference to `installed/`. This cache is
|
|
120
|
+
* also where private agent-workflow skills arrive from the per-station store
|
|
121
|
+
* (hasna-internal/fleet-resources) and get synced into agent folders.
|
|
104
122
|
*
|
|
105
123
|
* A missing explicit source is an error, not a fallback to "nothing": the whole point
|
|
106
124
|
* of zero-corpus is that sync must not silently sync an empty corpus because the
|
|
@@ -125,9 +143,11 @@ export interface WriteManagedAgentSkillParams {
|
|
|
125
143
|
* Write one skill into one agent's global folder, non-clobbering.
|
|
126
144
|
*
|
|
127
145
|
* A directory this tool has written before carries the marker file and is replaced with
|
|
128
|
-
* an exact mirror
|
|
129
|
-
*
|
|
130
|
-
*
|
|
146
|
+
* an exact mirror — except that a managed home holding full content is never silently
|
|
147
|
+
* 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.
|
|
131
151
|
*/
|
|
132
152
|
export declare function writeManagedAgentSkill(params: WriteManagedAgentSkillParams): AgentSyncAction;
|
|
133
153
|
export interface ManagedDirWriteResult {
|
package/dist/lib/auth-store.d.ts
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth.json lives at the skills app root, beside config.json.
|
|
3
|
+
*
|
|
4
|
+
* Resolved through getDataDir() so that $HASNA_SKILLS_DIR relocates the
|
|
5
|
+
* credential file along with the rest of the app's state. It used to be an
|
|
6
|
+
* import-time constant composed from homedir(), so with the override set the
|
|
7
|
+
* CLI stored its API key at ~/.hasna/skills/auth.json while config, corpus,
|
|
8
|
+
* and database all moved — the same override-only-half-works split getDataDir()
|
|
9
|
+
* documents for its own history.
|
|
10
|
+
*
|
|
11
|
+
* The legacy ~/.skills/auth.json stays a $HOME concern, exactly like
|
|
12
|
+
* getDataDir()'s legacy merge (which is deliberately skipped for an overridden
|
|
13
|
+
* dir): it is still read as a fallback and removed by clearAuthConfig(), but
|
|
14
|
+
* never written when the override is set. Like getDataDir(), the home is read
|
|
15
|
+
* from the environment at call time — os.homedir() caches its answer after the
|
|
16
|
+
* first call, so a process that re-points $HOME would keep composing the old
|
|
17
|
+
* path.
|
|
18
|
+
*/
|
|
19
|
+
export declare function getAuthFilePath(): string;
|
|
20
|
+
/**
|
|
21
|
+
* Write-free credential path resolution for read-only paths (e.g. `sync --dry-run`).
|
|
22
|
+
*
|
|
23
|
+
* getAuthFilePath() routes through getDataDir(), which WRITES (mkdirs the app dir,
|
|
24
|
+
* merges legacy ~/.skills content, copies the legacy config). A dry run must read
|
|
25
|
+
* the same credential file a real run would without performing any of that.
|
|
26
|
+
*/
|
|
27
|
+
export declare function getAuthFilePathReadOnly(): string;
|
|
1
28
|
/**
|
|
2
29
|
* Stored credentials for a Skills API instance.
|
|
3
30
|
*
|
|
@@ -18,6 +45,16 @@ export declare function getAuthConfig(): AuthConfig | null;
|
|
|
18
45
|
export declare function saveAuthConfig(config: AuthConfig): void;
|
|
19
46
|
export declare function clearAuthConfig(): void;
|
|
20
47
|
export declare function getApiKey(): string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Write-free credential read for read-only paths (e.g. `sync --dry-run`).
|
|
50
|
+
*
|
|
51
|
+
* getAuthConfig() resolves through getAuthFilePath() -> getDataDir(), which
|
|
52
|
+
* writes. Reads the same files (canonical auth.json, then the legacy ~/.skills
|
|
53
|
+
* fallback) without creating or migrating anything, and without touching the
|
|
54
|
+
* write path's module-level cache.
|
|
55
|
+
*/
|
|
56
|
+
export declare function getAuthConfigReadOnly(): AuthConfig | null;
|
|
57
|
+
export declare function getApiKeyReadOnly(): string | null;
|
|
21
58
|
export declare function normalizeSkillsApiOrigin(apiUrl: string): string;
|
|
22
59
|
/**
|
|
23
60
|
* Origin every credential-bearing request is sent to.
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -58,12 +58,63 @@ export declare const DATA_DIR_ENV = "HASNA_SKILLS_DIR";
|
|
|
58
58
|
* `custom` without colliding with anything.
|
|
59
59
|
*/
|
|
60
60
|
export declare const INSTALLED_SKILLS_DIRNAME = "installed";
|
|
61
|
+
/**
|
|
62
|
+
* Subfolder of the data directory holding the migrated corpus cache — the
|
|
63
|
+
* owner-layout replacement for installed/ after `skills storage migrate`
|
|
64
|
+
* (~/.hasna/skills/{skills,logs,outputs}).
|
|
65
|
+
*
|
|
66
|
+
* The marker file inside it (LAYOUT_MIGRATION_RECORD) is the authority: a
|
|
67
|
+
* skills/ directory someone created by hand is not the corpus and never will
|
|
68
|
+
* be treated as one (see isOwnerLayoutMigrated).
|
|
69
|
+
*/
|
|
70
|
+
export declare const SKILLS_CACHE_DIRNAME = "skills";
|
|
71
|
+
/**
|
|
72
|
+
* Marker file inside the corpus cache proving the owner-layout migration ran;
|
|
73
|
+
* also its record (see migrateOwnerLayout in home-migration.ts).
|
|
74
|
+
*/
|
|
75
|
+
export declare const LAYOUT_MIGRATION_RECORD = ".layout-migration.json";
|
|
76
|
+
/**
|
|
77
|
+
* True once the owner layout has been migrated (the record is the authority).
|
|
78
|
+
*
|
|
79
|
+
* Lives here — rather than in home-migration.ts — because the canonical corpus
|
|
80
|
+
* resolver in portable-skills.ts must consult it too, and home-migration.ts
|
|
81
|
+
* depends on portable-skills.ts; this module sits below both.
|
|
82
|
+
*/
|
|
83
|
+
export declare function isOwnerLayoutMigrated(appDir: string): boolean;
|
|
61
84
|
/**
|
|
62
85
|
* Get the data directory for skills global config/data.
|
|
63
86
|
* Default: ~/.hasna/skills/, overridable with $HASNA_SKILLS_DIR.
|
|
64
87
|
* Auto-migrates from ~/.skills/ and ~/.skillsrc without deleting legacy data.
|
|
65
88
|
*/
|
|
66
89
|
export declare function getDataDir(): string;
|
|
90
|
+
/**
|
|
91
|
+
* Write-free data-dir resolution for read-only paths (e.g. `sync --dry-run`).
|
|
92
|
+
*
|
|
93
|
+
* getDataDir() itself writes: it mkdirs the app folder, merges legacy ~/.skills
|
|
94
|
+
* content and copies the legacy config file. A dry run must resolve the SAME
|
|
95
|
+
* directory a real run would use without performing any of that — mirror the path
|
|
96
|
+
* logic only, reading $HASNA_SKILLS_DIR and $HOME exactly the way getDataDir does.
|
|
97
|
+
*/
|
|
98
|
+
export declare function getDataDirReadOnly(): string;
|
|
99
|
+
/**
|
|
100
|
+
* Get the config file path for a given scope, write-free (see getDataDirReadOnly).
|
|
101
|
+
*/
|
|
102
|
+
export declare function getConfigPathReadOnly(scope: ConfigScope): string;
|
|
103
|
+
/**
|
|
104
|
+
* Load merged config (project-local overrides global) without the writes
|
|
105
|
+
* getDataDir() performs on the write path.
|
|
106
|
+
*
|
|
107
|
+
* The write path folds the legacy ~/.skillsrc into canonical config.json as part of
|
|
108
|
+
* getDataDir()'s migration — copied ONLY when canonical config.json is absent, and
|
|
109
|
+
* the legacy migration is skipped entirely when a data-directory override is active.
|
|
110
|
+
* This mirrors that FILE-LEVEL precedence, never field-level merging: canonical
|
|
111
|
+
* config.json, when present, is the whole global config; legacy ~/.skillsrc is read
|
|
112
|
+
* only in the exact situation the write path would copy it (no canonical file, no
|
|
113
|
+
* override). Field-level merging would inherit a stale legacy origin beneath a
|
|
114
|
+
* canonical config that omits apiUrl — and the client sends its stored credential to
|
|
115
|
+
* whatever origin resolves, so divergence from the write path is credential-bearing.
|
|
116
|
+
*/
|
|
117
|
+
export declare function loadConfigReadOnly(): SkillsConfig;
|
|
67
118
|
/**
|
|
68
119
|
* Get the config file path for a given scope
|
|
69
120
|
*/
|
|
@@ -8,6 +8,10 @@ export interface DriftEntry {
|
|
|
8
8
|
path: string;
|
|
9
9
|
homeHash?: string;
|
|
10
10
|
canonicalHash?: string;
|
|
11
|
+
/** True when the home's SKILL.md is a sync pointer stub rather than real content. */
|
|
12
|
+
homeStub?: boolean;
|
|
13
|
+
/** True when the canonical corpus entry renders as a pointer stub (executable skill). */
|
|
14
|
+
canonicalStub?: boolean;
|
|
11
15
|
}
|
|
12
16
|
export interface DriftCensus {
|
|
13
17
|
entries: DriftEntry[];
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { type PortableSkillOptions } from "./portable-skills.js";
|
|
2
|
-
|
|
3
|
-
export declare const SKILLS_CACHE_DIRNAME = "skills";
|
|
2
|
+
export { LAYOUT_MIGRATION_RECORD, SKILLS_CACHE_DIRNAME, isOwnerLayoutMigrated } from "./config.js";
|
|
4
3
|
export declare const LOGS_DIRNAME = "logs";
|
|
5
4
|
export declare const OUTPUTS_DIRNAME = "outputs";
|
|
6
|
-
/** Marker file inside skills/ proving a migration ran; also its record. */
|
|
7
|
-
export declare const LAYOUT_MIGRATION_RECORD = ".layout-migration.json";
|
|
8
5
|
export declare const LEGACY_CUSTOM_DIRNAME = "custom";
|
|
9
6
|
export interface LayoutMigrationRecord {
|
|
10
7
|
version: 1;
|
|
@@ -15,19 +12,21 @@ export interface LayoutMigrationRecord {
|
|
|
15
12
|
note: string;
|
|
16
13
|
}
|
|
17
14
|
export declare function layoutMigrationRecordPath(appDir: string): string;
|
|
18
|
-
/** True once the owner layout has been migrated (the record is the authority). */
|
|
19
|
-
export declare function isOwnerLayoutMigrated(appDir: string): boolean;
|
|
20
15
|
/**
|
|
21
16
|
* Resolve the corpus the sync/fan-out reads from.
|
|
22
17
|
*
|
|
23
|
-
*
|
|
18
|
+
* The precedence lives in ONE place, getPortableSkillsRoot():
|
|
24
19
|
* 1. options.rootDir — named outright, no suffix (unchanged contract)
|
|
25
20
|
* 2. migrated owner layout — <app folder>/skills when a migration record exists
|
|
26
|
-
* 3. the pre-migration corpus —
|
|
27
|
-
* legacy auto-copy migration)
|
|
21
|
+
* 3. the pre-migration corpus — installed/, with the legacy auto-copy migration
|
|
28
22
|
*
|
|
29
23
|
* The migration record is required: a skills/ directory someone created by hand
|
|
30
24
|
* is not the corpus and never will be treated as one.
|
|
25
|
+
*
|
|
26
|
+
* This wrapper exists so pull/agent-sync and any future caller can name the
|
|
27
|
+
* canonical resolver explicitly; delegating keeps a single implementation for
|
|
28
|
+
* list/search/info/push/sync alike (bug 170b0e9b was exactly the opposite — a
|
|
29
|
+
* second resolution that read installed/ while this one read skills/).
|
|
31
30
|
*/
|
|
32
31
|
export declare function resolveCorpusRoot(options?: PortableSkillOptions): string;
|
|
33
32
|
export type LayoutMigrationStatus = "already-migrated" | "refused" | "migrated" | "nothing-to-do";
|
|
@@ -94,7 +94,7 @@ export declare function getStorageDatabaseEnv(env?: Record<string, string | unde
|
|
|
94
94
|
export declare function getSkillsStorageDatabaseUrl(env?: Record<string, string | undefined>): string | undefined;
|
|
95
95
|
export declare function getStorageDatabaseUrl(env?: Record<string, string | undefined>): string | undefined;
|
|
96
96
|
export interface SkillsNativeStorageStatus {
|
|
97
|
-
package: "
|
|
97
|
+
package: "skills";
|
|
98
98
|
tables: readonly SkillsStorageTable[];
|
|
99
99
|
env: {
|
|
100
100
|
databaseUrl: string;
|
|
@@ -7,11 +7,21 @@ export * from "./portable-skills-types.js";
|
|
|
7
7
|
/**
|
|
8
8
|
* Resolve the corpus: the directory holding one folder per installed skill.
|
|
9
9
|
*
|
|
10
|
+
* THIS IS THE ONE CANONICAL CORPUS RESOLUTION. Every local discovery and
|
|
11
|
+
* publish path — list, search, info, push, pull, sync, registry — reads the
|
|
12
|
+
* corpus through this function (directly or via resolveCorpusRoot(), which
|
|
13
|
+
* delegates here), so a migrated owner layout is never bypassed by a path that
|
|
14
|
+
* still resolves installed/ (bug 170b0e9b: 'skills list --all' returned 87
|
|
15
|
+
* entries while the migrated corpus held 688).
|
|
16
|
+
*
|
|
10
17
|
* Precedence is explicit-over-ambient, most specific first:
|
|
11
|
-
* 1. `options.rootDir`
|
|
12
|
-
* 2.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
18
|
+
* 1. `options.rootDir` - the corpus, named outright (no suffix)
|
|
19
|
+
* 2. the migrated owner layout - <app folder>/skills/ when a migration record
|
|
20
|
+
* exists there (the record is the authority; a skills/ directory someone
|
|
21
|
+
* created by hand is not the corpus)
|
|
22
|
+
* 3. `getDataDir()` - <app folder>/installed (pre-migration corpus,
|
|
23
|
+
* with the legacy auto-copy migration), where the app folder is
|
|
24
|
+
* $HASNA_SKILLS_DIR, else ~/.hasna/skills
|
|
15
25
|
*
|
|
16
26
|
* The app folder holds app data (config.json, skills.db, auth.json); the corpus
|
|
17
27
|
* is a named subfolder of it, matching every sibling Hasna app. One variable
|
|
@@ -46,6 +56,24 @@ export interface CorpusSkillMeta {
|
|
|
46
56
|
tags?: string[];
|
|
47
57
|
version?: string;
|
|
48
58
|
kind?: SkillKind;
|
|
59
|
+
/**
|
|
60
|
+
* The hosted registry's revision id for this skill (todos d061fcda), when the
|
|
61
|
+
* instance reported one. Carried onto the metadata-only pull path so the pull marker
|
|
62
|
+
* records which revision was installed there too.
|
|
63
|
+
*/
|
|
64
|
+
revisionId?: string;
|
|
65
|
+
/**
|
|
66
|
+
* The row's SKILL.md, served in the published metadata so a client can recompute the
|
|
67
|
+
* content-addressed revision id and PROVE the declared revision identifies the content
|
|
68
|
+
* it received (todos d061fcda).
|
|
69
|
+
*/
|
|
70
|
+
skillMd?: string;
|
|
71
|
+
/**
|
|
72
|
+
* The row's stored source (the payload's own `source` is always the client view
|
|
73
|
+
* "remote"). The canonical revision hash is computed over the stored value, so the
|
|
74
|
+
* client needs it to recompute the id.
|
|
75
|
+
*/
|
|
76
|
+
publishedSource?: string;
|
|
49
77
|
}
|
|
50
78
|
export interface WriteCorpusSkillInput {
|
|
51
79
|
name: string;
|
|
@@ -54,8 +82,9 @@ export interface WriteCorpusSkillInput {
|
|
|
54
82
|
meta?: CorpusSkillMeta | null;
|
|
55
83
|
}
|
|
56
84
|
/**
|
|
57
|
-
* Write a skill fetched from a Skills instance into the local corpus
|
|
58
|
-
*
|
|
85
|
+
* Write a skill fetched from a Skills instance into the local corpus (the
|
|
86
|
+
* canonical root — installed/ before the layout migration, <app folder>/skills/
|
|
87
|
+
* after it), so loadRegistry() surfaces it to both the CLI
|
|
59
88
|
* (`skills list --all`) and the MCP (`list_skills`) with no further step — the whole
|
|
60
89
|
* point of the pull: the corpus is already a first-class registry source.
|
|
61
90
|
*
|
package/dist/lib/pull.d.ts
CHANGED
|
@@ -5,6 +5,10 @@ import type { SkillKind } from "./registry-types.js";
|
|
|
5
5
|
export declare const BUNDLE_DIGEST_HEADER = "X-Skill-Bundle-Sha256";
|
|
6
6
|
/** Header carrying the HMAC signature of the served bundle, when the server can sign. */
|
|
7
7
|
export declare const BUNDLE_SIGNATURE_HEADER = "X-Skill-Bundle-Signature";
|
|
8
|
+
/** Header carrying the immutable revision identity of the served row (todos d061fcda). */
|
|
9
|
+
export declare const BUNDLE_REVISION_ID_HEADER = "X-Skill-Revision-Id";
|
|
10
|
+
/** Header carrying the per-slug write counter of the served row. */
|
|
11
|
+
export declare const BUNDLE_REVISION_NUMBER_HEADER = "X-Skill-Revision-Number";
|
|
8
12
|
/** Marker file written inside each corpus skill directory recording pull provenance. */
|
|
9
13
|
export declare const PULL_MARKER_FILE = ".hasna-skills.json";
|
|
10
14
|
/**
|
|
@@ -48,6 +52,26 @@ export interface PulledSkillResult {
|
|
|
48
52
|
sourceCommit?: string;
|
|
49
53
|
/** True when the pull created the corpus entry, false when it updated an existing one. */
|
|
50
54
|
created?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* The revision id (todos d061fcda) this pull installed, when the instance reported one.
|
|
57
|
+
* Recorded in the marker so a later pull can detect that the remote moved on.
|
|
58
|
+
*/
|
|
59
|
+
revisionId?: string;
|
|
60
|
+
/** True when the instance answered 410: the slug was deleted and pull reconciled. */
|
|
61
|
+
tombstoned?: boolean;
|
|
62
|
+
/** With `tombstoned`: true when a local corpus entry existed and was removed. */
|
|
63
|
+
removed?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* With `tombstoned`: true when the local corpus entry was NOT pull-managed (no pull
|
|
66
|
+
* marker), so it was left in place — a remote 410 never deletes a user-created skill.
|
|
67
|
+
*/
|
|
68
|
+
leftInPlace?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* True when the instance no longer serves a published revision under this slug (its
|
|
71
|
+
* tombstone window expired) but the local copy is a revision-marked published install.
|
|
72
|
+
* Reported instead of silently swapping in a bundled skill of the same name.
|
|
73
|
+
*/
|
|
74
|
+
purged?: boolean;
|
|
51
75
|
error?: string;
|
|
52
76
|
}
|
|
53
77
|
export interface PullSkillsResult {
|
|
@@ -66,6 +90,10 @@ export interface VerifiedBundle {
|
|
|
66
90
|
serverHash?: string;
|
|
67
91
|
/** The server-declared signature, when the header was present. */
|
|
68
92
|
signature?: string;
|
|
93
|
+
/** The server-declared revision id, when the header was present and well-formed. */
|
|
94
|
+
revisionId?: string;
|
|
95
|
+
/** The server-declared per-slug write counter, when the header was present. */
|
|
96
|
+
revisionNumber?: number;
|
|
69
97
|
}
|
|
70
98
|
export declare function pullSkills(options?: PullSkillsOptions): Promise<PullSkillsResult>;
|
|
71
99
|
/**
|
|
@@ -89,6 +117,7 @@ export declare function installBundleAtomically(name: string, entries: SkillBund
|
|
|
89
117
|
contentHash?: string;
|
|
90
118
|
sourceCommit?: string;
|
|
91
119
|
signature?: string;
|
|
120
|
+
revisionId?: string;
|
|
92
121
|
}): {
|
|
93
122
|
path: string;
|
|
94
123
|
created: boolean;
|
|
@@ -104,4 +133,6 @@ export declare function writePullMarker(dir: string, record: {
|
|
|
104
133
|
contentHash?: string;
|
|
105
134
|
sourceCommit?: string;
|
|
106
135
|
signature?: string;
|
|
136
|
+
revisionId?: string;
|
|
137
|
+
source?: "pull" | "sync";
|
|
107
138
|
}): void;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { type PortableSkillOptions } from "./portable-skills.js";
|
|
2
|
+
import { RemoteSkillsClient } from "./remote-client.js";
|
|
3
|
+
export type ReconcileConflictPolicy = "local" | "remote" | "skip";
|
|
4
|
+
/**
|
|
5
|
+
* The default policy, named exactly as the plan specifies: when the digests are identical
|
|
6
|
+
* the local state wins by construction (nothing to resolve); every other divergence is
|
|
7
|
+
* skipped and reported unless --conflict says otherwise.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_CONFLICT_POLICY: "local-wins-on-identical-digest-else-skip-and-report";
|
|
10
|
+
export declare const CONFLICT_POLICIES: readonly ReconcileConflictPolicy[];
|
|
11
|
+
/** Corpus-root cursor recording the last sync run. */
|
|
12
|
+
export declare const SYNC_CURSOR_FILE = ".sync-cursor.json";
|
|
13
|
+
export declare const SYNC_CURSOR_SCHEMA_VERSION: 1;
|
|
14
|
+
export interface ReconcileRegistryOptions extends PortableSkillOptions {
|
|
15
|
+
/** Push local-only, changed-locally, and conflicts won by local. */
|
|
16
|
+
push?: boolean;
|
|
17
|
+
/** Pull remote-only, changed-remotely, and conflicts won by remote. */
|
|
18
|
+
pull?: boolean;
|
|
19
|
+
/** Both directions (the default when neither --push nor --pull is given). */
|
|
20
|
+
all?: boolean;
|
|
21
|
+
/** Plan and report without writing anything. */
|
|
22
|
+
dryRun?: boolean;
|
|
23
|
+
/** Conflict resolution policy. */
|
|
24
|
+
conflict?: ReconcileConflictPolicy;
|
|
25
|
+
/** Client override. `undefined` resolves one from configuration; `null` models "no credential". */
|
|
26
|
+
client?: RemoteSkillsClient | null;
|
|
27
|
+
/** HMAC signing key for bundle signature verification. Defaults to $SKILLS_SIGNING_KEY. */
|
|
28
|
+
signingKey?: string;
|
|
29
|
+
}
|
|
30
|
+
export type ReconcileSkillState = "local-only" | "remote-only" | "changed-locally" | "changed-remotely" | "conflict" | "in-sync";
|
|
31
|
+
export type ReconcileAction = "push" | "pull" | "skip" | "none";
|
|
32
|
+
export interface ReconcileSkillEntry {
|
|
33
|
+
slug: string;
|
|
34
|
+
state: ReconcileSkillState;
|
|
35
|
+
action: ReconcileAction;
|
|
36
|
+
localVersion?: string;
|
|
37
|
+
remoteVersion?: string;
|
|
38
|
+
localSha256?: string;
|
|
39
|
+
remoteSha256?: string;
|
|
40
|
+
/** Why the entry was classified or skipped this way. */
|
|
41
|
+
reason?: string;
|
|
42
|
+
/** Present only for executed (non-dry-run) actions. */
|
|
43
|
+
result?: {
|
|
44
|
+
ok: boolean;
|
|
45
|
+
detail?: string;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export interface ReconcileSummary {
|
|
49
|
+
/** Skills found in the local corpus. */
|
|
50
|
+
local: number;
|
|
51
|
+
/** Skills the registry serves (published plus bundled). */
|
|
52
|
+
remote: number;
|
|
53
|
+
inSync: number;
|
|
54
|
+
pushed: number;
|
|
55
|
+
pulled: number;
|
|
56
|
+
/** Divergences the conflict policy had to resolve (or decline to resolve). */
|
|
57
|
+
conflicts: number;
|
|
58
|
+
/** Divergences skipped under the policy. */
|
|
59
|
+
skipped: number;
|
|
60
|
+
errors: number;
|
|
61
|
+
}
|
|
62
|
+
export interface ReconcileCursor {
|
|
63
|
+
schemaVersion: typeof SYNC_CURSOR_SCHEMA_VERSION;
|
|
64
|
+
managedBy: string;
|
|
65
|
+
lastSyncedAt: string;
|
|
66
|
+
runCount: number;
|
|
67
|
+
summary: ReconcileSummary;
|
|
68
|
+
}
|
|
69
|
+
export interface ReconcileRegistryResult {
|
|
70
|
+
corpusRoot: string;
|
|
71
|
+
/** True when a real run would first migrate the legacy corpus layout into place. */
|
|
72
|
+
migrationPending: boolean;
|
|
73
|
+
direction: "push" | "pull" | "all";
|
|
74
|
+
dryRun: boolean;
|
|
75
|
+
conflictPolicy: ReconcileConflictPolicy;
|
|
76
|
+
/** The full declared policy label; identical to conflictPolicy for local/remote. */
|
|
77
|
+
conflictPolicyDescription: string;
|
|
78
|
+
summary: ReconcileSummary;
|
|
79
|
+
skills: ReconcileSkillEntry[];
|
|
80
|
+
/** Present only when the run was real and completed without errors. */
|
|
81
|
+
cursor?: Pick<ReconcileCursor, "lastSyncedAt" | "runCount">;
|
|
82
|
+
}
|
|
83
|
+
export declare class ReconcileRegistryError extends Error {
|
|
84
|
+
readonly detail?: string[] | undefined;
|
|
85
|
+
constructor(message: string, detail?: string[] | undefined);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Re-check the local side of one pull candidate, immediately before the pull batch.
|
|
89
|
+
*
|
|
90
|
+
* Packs FIRST, then samples existence: a directory that appeared between the plan and
|
|
91
|
+
* the pack is provably on disk when the sample runs. Sampling before the pack let a
|
|
92
|
+
* concurrent editor's partial tree read as "still absent" — the pack then threw on it
|
|
93
|
+
* while the stale sample stayed false, so localMoved was false and the pull replaced
|
|
94
|
+
* the newly created directory (review P1). The ops seam exists so a test can pin that
|
|
95
|
+
* ordering deterministically: pack throws on the partial tree, the post-pack sample
|
|
96
|
+
* sees it, and the candidate counts as moved.
|
|
97
|
+
*
|
|
98
|
+
* Returns true when the local side moved since the plan: a digest different from the
|
|
99
|
+
* planned one, or a directory present where the plan saw none (a concurrent editor).
|
|
100
|
+
*/
|
|
101
|
+
export declare function recheckLocalSide(plannedLocal: string | undefined, localDir: string, ops?: {
|
|
102
|
+
pack: (dir: string) => string;
|
|
103
|
+
exists: (dir: string) => boolean;
|
|
104
|
+
}): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Run one sync pass.
|
|
107
|
+
*
|
|
108
|
+
* The plan (classification + per-skill action) is computed in full before anything is
|
|
109
|
+
* executed, and a dry run stops at the plan: no publish, no pull, no marker, no cursor —
|
|
110
|
+
* proven by readback in the tests. Every executed mutation re-checks both sides first
|
|
111
|
+
* (see the module header); a side that moved since the plan skips that skill and reports
|
|
112
|
+
* it rather than overwriting state the plan did not see.
|
|
113
|
+
*/
|
|
114
|
+
export declare function reconcileRegistry(options?: ReconcileRegistryOptions): Promise<ReconcileRegistryResult>;
|
package/dist/lib/registry.d.ts
CHANGED
|
@@ -9,12 +9,15 @@ export declare function isBasicSkillName(name: string): boolean;
|
|
|
9
9
|
export declare function findExtensionSkillPath(name: string): string | null;
|
|
10
10
|
/**
|
|
11
11
|
* Load the full registry: official skills merged with a configured private
|
|
12
|
-
* extension checkout and global custom skills from
|
|
13
|
-
*
|
|
12
|
+
* extension checkout and global custom skills from the canonical local corpus
|
|
13
|
+
* — <app folder>/installed/<name>/ before the owner-layout migration, <app
|
|
14
|
+
* folder>/skills/<name>/ after it (resolveCorpusRoot(), the one resolution
|
|
15
|
+
* every discovery path shares) — plus the legacy ~/.hasna/skills/custom/<name>/
|
|
16
|
+
* migration safety net.
|
|
14
17
|
*
|
|
15
18
|
* Custom skills take precedence over extensions, which take precedence over
|
|
16
|
-
* official skills. Extension skills are read in place and never copied into
|
|
17
|
-
* Results are cached for 5 seconds.
|
|
19
|
+
* official skills. Extension skills are read in place and never copied into the
|
|
20
|
+
* corpus. Results are cached for 5 seconds.
|
|
18
21
|
*/
|
|
19
22
|
export declare function loadRegistry(cwd?: string): SkillMeta[];
|
|
20
23
|
export declare function loadBasicRegistry(cwd?: string): SkillMeta[];
|
|
@@ -1,12 +1,83 @@
|
|
|
1
1
|
import { type RemoteSkillRunContract } from "./remote-run-contract.js";
|
|
2
|
+
/**
|
|
3
|
+
* A server that predates this client's pin/tag/incremental-sync routes answered
|
|
4
|
+
* 404/405 for them. The caller must never mistake that for "no pins" or "empty
|
|
5
|
+
* listing" — a silently-empty sync would look like success and drop nothing on
|
|
6
|
+
* the next push. This error is how the version-skew surfaces fail-closed.
|
|
7
|
+
*/
|
|
8
|
+
export declare class RemoteRouteUnsupportedError extends Error {
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly status: number;
|
|
11
|
+
readonly instance: string;
|
|
12
|
+
constructor(path: string, status: number, instance: string);
|
|
13
|
+
}
|
|
14
|
+
/** Any other non-ok response on the new-route methods, with the status attached. */
|
|
15
|
+
export declare class RemoteRequestError extends Error {
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly status: number;
|
|
18
|
+
constructor(path: string, status: number, statusText: string);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A remote pin on a skill, matching the hosted-pins wire shape
|
|
22
|
+
* (`{ slug, pinnedAt, metadata }`). `pinnedAt`/`metadata` are server-reported
|
|
23
|
+
* and may be absent.
|
|
24
|
+
*/
|
|
25
|
+
export interface RemotePin {
|
|
26
|
+
slug: string;
|
|
27
|
+
pinnedAt?: string;
|
|
28
|
+
metadata?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
/** The minimal per-skill row the pin/tag/updated-since routes serve. */
|
|
31
|
+
export interface RemoteSkillSummary {
|
|
32
|
+
slug: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
version?: string;
|
|
35
|
+
updatedAt?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* One page of an incremental listing. `nextCursor` is an opaque continuation
|
|
39
|
+
* token; null (or an absent field) means the listing is complete.
|
|
40
|
+
*/
|
|
41
|
+
export interface UpdatedSincePage {
|
|
42
|
+
skills: RemoteSkillSummary[];
|
|
43
|
+
nextCursor: string | null;
|
|
44
|
+
}
|
|
2
45
|
export declare class RemoteSkillsClient {
|
|
3
46
|
private apiUrl;
|
|
4
47
|
private apiKey;
|
|
5
48
|
constructor(apiKey: string, apiUrl?: string);
|
|
6
49
|
private request;
|
|
50
|
+
/**
|
|
51
|
+
* Fail-closed version-skew guard for the pin/tag/updated-since routes.
|
|
52
|
+
*
|
|
53
|
+
* A server that predates these routes answers 404 (unmatched path) or 405
|
|
54
|
+
* (unmatched method). Both are surfaced as `RemoteRouteUnsupportedError` —
|
|
55
|
+
* never as an empty listing, which would read as "no pins / no changes" and
|
|
56
|
+
* silently desynchronize the caller. Every other non-ok response becomes a
|
|
57
|
+
* `RemoteRequestError` carrying the status.
|
|
58
|
+
*
|
|
59
|
+
* `domainNotFoundCodes` is the one deliberate exception: a route the server
|
|
60
|
+
* DOES have can 404 for a domain reason (the hosted-pins DELETE answers
|
|
61
|
+
* `{ code: "PIN_NOT_FOUND" }` when no pin exists). A 404 whose JSON body
|
|
62
|
+
* carries one of those codes is returned to the caller (status intact) so it
|
|
63
|
+
* can apply domain semantics instead of misreporting version skew. Every
|
|
64
|
+
* other 404 — including the dispatcher's `{ code: "NOT_FOUND" }` on a route
|
|
65
|
+
* the server lacks — still throws `RemoteRouteUnsupportedError`.
|
|
66
|
+
*/
|
|
67
|
+
private requestNewRoute;
|
|
7
68
|
listSkills(): Promise<any[]>;
|
|
8
69
|
getSkillMd(slug: string): Promise<string | null>;
|
|
9
70
|
getSkill(slug: string): Promise<any | null>;
|
|
71
|
+
/**
|
|
72
|
+
* Raw GET for one skill, with the HTTP status surfaced. Used by the reconcile
|
|
73
|
+
* re-check (registry-reconcile.ts) so it can distinguish "no such skill" (404) from
|
|
74
|
+
* "the registry failed to answer" (any other non-success status) instead of treating
|
|
75
|
+
* both as absent.
|
|
76
|
+
*/
|
|
77
|
+
getSkillStatus(slug: string): Promise<{
|
|
78
|
+
status: number;
|
|
79
|
+
body: unknown;
|
|
80
|
+
}>;
|
|
10
81
|
submitRun(slug: string, input?: Record<string, unknown>, args?: string[]): Promise<RemoteSkillRunContract>;
|
|
11
82
|
getRun(runId: string): Promise<RemoteSkillRunContract | null>;
|
|
12
83
|
getRunLogs(runId: string): Promise<any[]>;
|
|
@@ -24,8 +95,13 @@ export declare class RemoteSkillsClient {
|
|
|
24
95
|
* Note the deliberate absence of `request()`: that helper pins
|
|
25
96
|
* `Content-Type: application/json`, and a multipart body whose Content-Type does not
|
|
26
97
|
* carry the generated boundary is unparseable at the other end.
|
|
98
|
+
*
|
|
99
|
+
* Optimistic concurrency (todos d061fcda): pass the revision id this client last read
|
|
100
|
+
* for the slug (from getSkill().revisionId) as `ifMatch`. The instance refuses a
|
|
101
|
+
* publish against a live slug that does not name its current revision with 409 — this
|
|
102
|
+
* is how a push never silently overwrites a newer remote revision.
|
|
27
103
|
*/
|
|
28
|
-
publishSkill(manifest: Record<string, unknown>, bundle?: Uint8Array): Promise<Response>;
|
|
104
|
+
publishSkill(manifest: Record<string, unknown>, bundle?: Uint8Array, ifMatch?: string): Promise<Response>;
|
|
29
105
|
deleteSkill(slug: string): Promise<Response>;
|
|
30
106
|
downloadSkillBundle(slug: string): Promise<Response>;
|
|
31
107
|
/**
|
|
@@ -34,5 +110,46 @@ export declare class RemoteSkillsClient {
|
|
|
34
110
|
* instance serves no bundle for this skill (the metadata-only fallback path).
|
|
35
111
|
*/
|
|
36
112
|
getBundle(slug: string): Promise<Response | null>;
|
|
113
|
+
/** List the pins the instance holds for this principal. */
|
|
114
|
+
listPins(): Promise<RemotePin[]>;
|
|
115
|
+
/**
|
|
116
|
+
* Pin a skill on the instance (upsert — pinning again refreshes it). The
|
|
117
|
+
* wire contract matches the hosted-pins routes: a PUT with an optional
|
|
118
|
+
* `{ metadata }` body, answered with the stored pin (`slug`, `pinnedAt`,
|
|
119
|
+
* `metadata`).
|
|
120
|
+
*/
|
|
121
|
+
pin(slug: string, metadata?: Record<string, unknown>): Promise<RemotePin>;
|
|
122
|
+
/**
|
|
123
|
+
* Unpin a skill on the instance. Resolves true when a pin existed and was
|
|
124
|
+
* deleted; false when the instance has no pin for this slug (its 404
|
|
125
|
+
* carries `code: "PIN_NOT_FOUND"` — a domain answer, not version skew). A
|
|
126
|
+
* bare 404 (route not deployed) still throws `RemoteRouteUnsupportedError`.
|
|
127
|
+
*/
|
|
128
|
+
unpin(slug: string): Promise<boolean>;
|
|
129
|
+
/** List the tag names the instance serves. */
|
|
130
|
+
listTags(): Promise<string[]>;
|
|
131
|
+
/** List the skills carrying a tag on the instance. */
|
|
132
|
+
skillsByTag(tag: string): Promise<RemoteSkillSummary[]>;
|
|
133
|
+
/**
|
|
134
|
+
* Cursor-based incremental listing of skills updated after `since` (ISO 8601).
|
|
135
|
+
* Each page carries an opaque `nextCursor`; null means the listing is complete.
|
|
136
|
+
* This is the feed T9's sync reconciliation verb consumes.
|
|
137
|
+
*/
|
|
138
|
+
listUpdatedSince(since: string, options?: {
|
|
139
|
+
cursor?: string;
|
|
140
|
+
limit?: number;
|
|
141
|
+
}): Promise<UpdatedSincePage>;
|
|
37
142
|
}
|
|
38
143
|
export declare function createRemoteSkillsClient(): RemoteSkillsClient | null;
|
|
144
|
+
/**
|
|
145
|
+
* Write-free client resolution for read-only paths (e.g. `sync --dry-run`).
|
|
146
|
+
*
|
|
147
|
+
* createRemoteSkillsClient() resolves the stored credential through
|
|
148
|
+
* getAuthFilePath() and the stored origin through loadConfig() — both route
|
|
149
|
+
* through getDataDir(), which WRITES (mkdirs the app dir, merges legacy ~/.skills
|
|
150
|
+
* content, copies the legacy config). A dry run must resolve the same client a
|
|
151
|
+
* real run would without performing any of that: the credential and the origin
|
|
152
|
+
* are read from the same files at the same computed paths, and the MissingApiUrl
|
|
153
|
+
* failure mode is preserved (a key with no origin still fails loudly).
|
|
154
|
+
*/
|
|
155
|
+
export declare function createRemoteSkillsClientReadOnly(): RemoteSkillsClient | null;
|