@lythos/cold-pool 0.9.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/LICENSE +21 -0
- package/README.md +47 -0
- package/package.json +39 -0
- package/src/cold-pool.test.ts +101 -0
- package/src/cold-pool.ts +87 -0
- package/src/fetch-plan.test.ts +117 -0
- package/src/fetch-plan.ts +72 -0
- package/src/git-io.test.ts +54 -0
- package/src/git-io.ts +90 -0
- package/src/github-tree.test.ts +100 -0
- package/src/github-tree.ts +111 -0
- package/src/index.ts +33 -0
- package/src/infer-skill-path.test.ts +72 -0
- package/src/infer-skill-path.ts +43 -0
- package/src/parse-locator.test.ts +125 -0
- package/src/parse-locator.ts +45 -0
- package/src/types.ts +83 -0
- package/src/validate-plan.test.ts +162 -0
- package/src/validate-plan.ts +259 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { fetchRepoTree, type FetchFn, type TreeEntry } from './github-tree'
|
|
3
|
+
|
|
4
|
+
function mockFetch(impl: (url: string, init?: RequestInit) => {
|
|
5
|
+
status: number
|
|
6
|
+
body?: unknown
|
|
7
|
+
headers?: Record<string, string>
|
|
8
|
+
error?: Error
|
|
9
|
+
}): FetchFn {
|
|
10
|
+
return async (url, init) => {
|
|
11
|
+
const out = impl(url, init)
|
|
12
|
+
if (out.error) throw out.error
|
|
13
|
+
return new Response(out.body !== undefined ? JSON.stringify(out.body) : null, {
|
|
14
|
+
status: out.status,
|
|
15
|
+
headers: out.headers,
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('fetchRepoTree', () => {
|
|
21
|
+
test('200 → ok with entries', async () => {
|
|
22
|
+
const entries: TreeEntry[] = [
|
|
23
|
+
{ path: 'README.md', type: 'blob', sha: 'a' },
|
|
24
|
+
{ path: 'skills/pdf/SKILL.md', type: 'blob', sha: 'b' },
|
|
25
|
+
]
|
|
26
|
+
const fetch = mockFetch((url) => {
|
|
27
|
+
expect(url).toBe('https://api.github.com/repos/anthropics/skills/git/trees/HEAD?recursive=1')
|
|
28
|
+
return { status: 200, body: { tree: entries, truncated: false } }
|
|
29
|
+
})
|
|
30
|
+
const res = await fetchRepoTree('github.com', 'anthropics', 'skills', 'HEAD', fetch)
|
|
31
|
+
expect(res.status).toBe('ok')
|
|
32
|
+
expect(res.entries).toEqual(entries)
|
|
33
|
+
expect(res.httpStatus).toBe(200)
|
|
34
|
+
expect(res.truncated).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('200 with truncated', async () => {
|
|
38
|
+
const fetch = mockFetch(() => ({
|
|
39
|
+
status: 200,
|
|
40
|
+
body: { tree: [], truncated: true },
|
|
41
|
+
}))
|
|
42
|
+
const res = await fetchRepoTree('github.com', 'o', 'r', 'HEAD', fetch)
|
|
43
|
+
expect(res.truncated).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('404 → not-found', async () => {
|
|
47
|
+
const fetch = mockFetch(() => ({ status: 404 }))
|
|
48
|
+
const res = await fetchRepoTree('github.com', 'nope', 'nope', 'HEAD', fetch)
|
|
49
|
+
expect(res.status).toBe('not-found')
|
|
50
|
+
expect(res.httpStatus).toBe(404)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('403 with X-RateLimit-Remaining: 0 → rate-limited', async () => {
|
|
54
|
+
const fetch = mockFetch(() => ({
|
|
55
|
+
status: 403,
|
|
56
|
+
headers: { 'X-RateLimit-Remaining': '0' },
|
|
57
|
+
}))
|
|
58
|
+
const res = await fetchRepoTree('github.com', 'o', 'r', 'HEAD', fetch)
|
|
59
|
+
expect(res.status).toBe('rate-limited')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('403 without rate-limit headers → private', async () => {
|
|
63
|
+
const fetch = mockFetch(() => ({ status: 403 }))
|
|
64
|
+
const res = await fetchRepoTree('github.com', 'o', 'r', 'HEAD', fetch)
|
|
65
|
+
expect(res.status).toBe('private')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('network error → network-error', async () => {
|
|
69
|
+
const fetch = mockFetch(() => ({ status: 0, error: new Error('ENOTFOUND') }))
|
|
70
|
+
const res = await fetchRepoTree('github.com', 'o', 'r', 'HEAD', fetch)
|
|
71
|
+
expect(res.status).toBe('network-error')
|
|
72
|
+
expect(res.message).toContain('ENOTFOUND')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('non-github host → unsupported-host', async () => {
|
|
76
|
+
const fetch = mockFetch(() => ({ status: 200 })) // would be ok if fetched
|
|
77
|
+
const res = await fetchRepoTree('gitlab.com', 'o', 'r', 'HEAD', fetch)
|
|
78
|
+
expect(res.status).toBe('unsupported-host')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('default ref is HEAD', async () => {
|
|
82
|
+
let capturedUrl = ''
|
|
83
|
+
const fetch = mockFetch((url) => {
|
|
84
|
+
capturedUrl = url
|
|
85
|
+
return { status: 200, body: { tree: [] } }
|
|
86
|
+
})
|
|
87
|
+
await fetchRepoTree('github.com', 'o', 'r', undefined, fetch)
|
|
88
|
+
expect(capturedUrl).toContain('/trees/HEAD?recursive=1')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('explicit ref is honored', async () => {
|
|
92
|
+
let capturedUrl = ''
|
|
93
|
+
const fetch = mockFetch((url) => {
|
|
94
|
+
capturedUrl = url
|
|
95
|
+
return { status: 200, body: { tree: [] } }
|
|
96
|
+
})
|
|
97
|
+
await fetchRepoTree('github.com', 'o', 'r', 'v1.2.3', fetch)
|
|
98
|
+
expect(capturedUrl).toContain('/trees/v1.2.3?recursive=1')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight GitHub Tree API client.
|
|
3
|
+
*
|
|
4
|
+
* No SDK, no auth handling — public repos only via built-in `fetch`.
|
|
5
|
+
* One function: `fetchRepoTree`. Caller passes a fetch implementation
|
|
6
|
+
* for testing; default is `globalThis.fetch`.
|
|
7
|
+
*
|
|
8
|
+
* Endpoint: GET https://api.github.com/repos/{owner}/{repo}/git/trees/{ref}?recursive=1
|
|
9
|
+
* 200 → tree returned (with optional `truncated: true` for >7MB or 100k entries)
|
|
10
|
+
* 404 → repo or ref not found
|
|
11
|
+
* 403 → rate-limited (X-RateLimit-Remaining: 0) or private repo without auth
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface TreeEntry {
|
|
15
|
+
readonly path: string
|
|
16
|
+
readonly type: 'blob' | 'tree' | 'commit'
|
|
17
|
+
readonly sha: string
|
|
18
|
+
readonly size?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TreeResponse {
|
|
22
|
+
readonly status: 'ok' | 'not-found' | 'rate-limited' | 'private' | 'network-error' | 'unsupported-host'
|
|
23
|
+
readonly entries: ReadonlyArray<TreeEntry>
|
|
24
|
+
readonly truncated: boolean
|
|
25
|
+
readonly httpStatus: number
|
|
26
|
+
readonly message?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type FetchFn = (url: string, init?: RequestInit) => Promise<Response>
|
|
30
|
+
|
|
31
|
+
const DEFAULT_REF = 'HEAD'
|
|
32
|
+
|
|
33
|
+
export async function fetchRepoTree(
|
|
34
|
+
host: string,
|
|
35
|
+
owner: string,
|
|
36
|
+
repo: string,
|
|
37
|
+
ref: string = DEFAULT_REF,
|
|
38
|
+
fetchImpl: FetchFn = globalThis.fetch,
|
|
39
|
+
): Promise<TreeResponse> {
|
|
40
|
+
if (host !== 'github.com') {
|
|
41
|
+
return {
|
|
42
|
+
status: 'unsupported-host',
|
|
43
|
+
entries: [],
|
|
44
|
+
truncated: false,
|
|
45
|
+
httpStatus: 0,
|
|
46
|
+
message: `host '${host}' is not supported by fetchRepoTree (only github.com for now)`,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`
|
|
51
|
+
|
|
52
|
+
let res: Response
|
|
53
|
+
try {
|
|
54
|
+
res = await fetchImpl(url, {
|
|
55
|
+
headers: {
|
|
56
|
+
Accept: 'application/vnd.github+json',
|
|
57
|
+
'User-Agent': '@lythos/cold-pool',
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return {
|
|
62
|
+
status: 'network-error',
|
|
63
|
+
entries: [],
|
|
64
|
+
truncated: false,
|
|
65
|
+
httpStatus: 0,
|
|
66
|
+
message: err instanceof Error ? err.message : String(err),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (res.status === 404) {
|
|
71
|
+
return {
|
|
72
|
+
status: 'not-found',
|
|
73
|
+
entries: [],
|
|
74
|
+
truncated: false,
|
|
75
|
+
httpStatus: 404,
|
|
76
|
+
message: `repo '${owner}/${repo}' or ref '${ref}' not found on github.com`,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (res.status === 403) {
|
|
81
|
+
const remaining = res.headers.get('X-RateLimit-Remaining')
|
|
82
|
+
const isRateLimited = remaining === '0'
|
|
83
|
+
return {
|
|
84
|
+
status: isRateLimited ? 'rate-limited' : 'private',
|
|
85
|
+
entries: [],
|
|
86
|
+
truncated: false,
|
|
87
|
+
httpStatus: 403,
|
|
88
|
+
message: isRateLimited
|
|
89
|
+
? 'github.com api rate limit exceeded for unauthenticated requests'
|
|
90
|
+
: `repo '${owner}/${repo}' is private (or auth required)`,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
return {
|
|
96
|
+
status: 'network-error',
|
|
97
|
+
entries: [],
|
|
98
|
+
truncated: false,
|
|
99
|
+
httpStatus: res.status,
|
|
100
|
+
message: `unexpected http ${res.status}`,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const body = await res.json() as { tree?: TreeEntry[]; truncated?: boolean }
|
|
105
|
+
return {
|
|
106
|
+
status: 'ok',
|
|
107
|
+
entries: body.tree ?? [],
|
|
108
|
+
truncated: body.truncated ?? false,
|
|
109
|
+
httpStatus: 200,
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lythos/cold-pool — Cold pool service layer.
|
|
3
|
+
*
|
|
4
|
+
* Resource layer: `ColdPool`
|
|
5
|
+
* Plan layer: `parseLocator`, `buildValidationPlan`, `buildFetchPlan`
|
|
6
|
+
* Execute layer: `executeValidationPlan`, `executeFetchPlan`, git IO primitives
|
|
7
|
+
*/
|
|
8
|
+
export type {
|
|
9
|
+
Locator,
|
|
10
|
+
ValidationReport,
|
|
11
|
+
ValidationFindings,
|
|
12
|
+
SuggestedFix,
|
|
13
|
+
FetchPlan,
|
|
14
|
+
FetchResult,
|
|
15
|
+
FetchIO,
|
|
16
|
+
} from './types.js'
|
|
17
|
+
|
|
18
|
+
export { parseLocator, formatLocator } from './parse-locator.js'
|
|
19
|
+
export { ColdPool, DEFAULT_COLD_POOL_PATH } from './cold-pool.js'
|
|
20
|
+
|
|
21
|
+
export type { TreeEntry, TreeResponse, FetchFn } from './github-tree.js'
|
|
22
|
+
export { fetchRepoTree } from './github-tree.js'
|
|
23
|
+
|
|
24
|
+
export type { InferenceResult } from './infer-skill-path.js'
|
|
25
|
+
export { inferSkillPath } from './infer-skill-path.js'
|
|
26
|
+
|
|
27
|
+
export type { ValidationPlan, ValidationCheck, ValidationIO } from './validate-plan.js'
|
|
28
|
+
export { buildValidationPlan, executeValidationPlan } from './validate-plan.js'
|
|
29
|
+
|
|
30
|
+
export type { GitCloneOptions, GitPullResult, GitRootResult } from './git-io.js'
|
|
31
|
+
export { gitClone, gitPull, detectGitRoot } from './git-io.js'
|
|
32
|
+
|
|
33
|
+
export { buildFetchPlan, executeFetchPlan } from './fetch-plan.js'
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { inferSkillPath } from './infer-skill-path'
|
|
3
|
+
import type { TreeEntry } from './github-tree'
|
|
4
|
+
|
|
5
|
+
const blob = (path: string): TreeEntry => ({ path, type: 'blob', sha: 'x' })
|
|
6
|
+
const tree = (path: string): TreeEntry => ({ path, type: 'tree', sha: 'x' })
|
|
7
|
+
|
|
8
|
+
describe('inferSkillPath', () => {
|
|
9
|
+
test('repo-root SKILL.md → "" candidate', () => {
|
|
10
|
+
const r = inferSkillPath([blob('SKILL.md'), blob('README.md')])
|
|
11
|
+
expect(r.candidates).toEqual([''])
|
|
12
|
+
expect(r.exactMatch).toBeNull()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('monorepo with multiple skills', () => {
|
|
16
|
+
const r = inferSkillPath([
|
|
17
|
+
blob('skills/pdf/SKILL.md'),
|
|
18
|
+
blob('skills/pdf/script.py'),
|
|
19
|
+
blob('skills/excel/SKILL.md'),
|
|
20
|
+
tree('skills/empty'),
|
|
21
|
+
])
|
|
22
|
+
expect(r.candidates.sort()).toEqual(['skills/excel', 'skills/pdf'])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('flat repo (root-level skill dirs)', () => {
|
|
26
|
+
const r = inferSkillPath([
|
|
27
|
+
blob('skill-creator/SKILL.md'),
|
|
28
|
+
blob('competitors-analysis/SKILL.md'),
|
|
29
|
+
blob('README.md'),
|
|
30
|
+
])
|
|
31
|
+
expect(r.candidates.sort()).toEqual(['competitors-analysis', 'skill-creator'])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('nested monorepo (skills/category/skill)', () => {
|
|
35
|
+
const r = inferSkillPath([
|
|
36
|
+
blob('skills/engineering/tdd/SKILL.md'),
|
|
37
|
+
blob('skills/engineering/diagnose/SKILL.md'),
|
|
38
|
+
])
|
|
39
|
+
expect(r.candidates.sort()).toEqual(['skills/engineering/diagnose', 'skills/engineering/tdd'])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('exactMatch reflects expectedSubpath when present', () => {
|
|
43
|
+
const r = inferSkillPath(
|
|
44
|
+
[blob('skills/pdf/SKILL.md'), blob('skills/excel/SKILL.md')],
|
|
45
|
+
'skills/pdf',
|
|
46
|
+
)
|
|
47
|
+
expect(r.exactMatch).toBe('skills/pdf')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('exactMatch is null when expectedSubpath is wrong', () => {
|
|
51
|
+
const r = inferSkillPath(
|
|
52
|
+
[blob('skills/pdf/SKILL.md')],
|
|
53
|
+
'pdf', // missing the skills/ prefix
|
|
54
|
+
)
|
|
55
|
+
expect(r.exactMatch).toBeNull()
|
|
56
|
+
expect(r.candidates).toEqual(['skills/pdf'])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('tree-type entries are ignored (only blobs counted)', () => {
|
|
60
|
+
const r = inferSkillPath([
|
|
61
|
+
tree('SKILL.md'), // not a real file (tree, not blob)
|
|
62
|
+
blob('skills/x/SKILL.md'),
|
|
63
|
+
])
|
|
64
|
+
expect(r.candidates).toEqual(['skills/x'])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('empty entries → empty candidates', () => {
|
|
68
|
+
const r = inferSkillPath([])
|
|
69
|
+
expect(r.candidates).toEqual([])
|
|
70
|
+
expect(r.exactMatch).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure scan of a tree response for SKILL.md candidates.
|
|
3
|
+
*
|
|
4
|
+
* Used by validation when a locator's expected skill subpath does not
|
|
5
|
+
* resolve, or when no skill subpath was given (standalone or ambiguous).
|
|
6
|
+
*/
|
|
7
|
+
import type { TreeEntry } from './github-tree.js'
|
|
8
|
+
|
|
9
|
+
export interface InferenceResult {
|
|
10
|
+
/** Directories within the repo that contain a SKILL.md (relative to repo root). Empty string = repo root. */
|
|
11
|
+
readonly candidates: ReadonlyArray<string>
|
|
12
|
+
/** When `expectedSubpath` is given: the matching candidate, if exact. */
|
|
13
|
+
readonly exactMatch: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Scan a flat tree listing for paths ending in `SKILL.md`. Returns the
|
|
18
|
+
* containing directory paths (relative to repo root). When
|
|
19
|
+
* `expectedSubpath` is given, also reports whether an exact match exists.
|
|
20
|
+
*/
|
|
21
|
+
export function inferSkillPath(
|
|
22
|
+
entries: ReadonlyArray<TreeEntry>,
|
|
23
|
+
expectedSubpath?: string | null,
|
|
24
|
+
): InferenceResult {
|
|
25
|
+
const candidates: string[] = []
|
|
26
|
+
for (const e of entries) {
|
|
27
|
+
if (e.type !== 'blob') continue
|
|
28
|
+
if (e.path === 'SKILL.md') {
|
|
29
|
+
candidates.push('')
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
if (e.path.endsWith('/SKILL.md')) {
|
|
33
|
+
candidates.push(e.path.slice(0, -'/SKILL.md'.length))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!expectedSubpath) {
|
|
38
|
+
return { candidates, exactMatch: null }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const exactMatch = candidates.includes(expectedSubpath) ? expectedSubpath : null
|
|
42
|
+
return { candidates, exactMatch }
|
|
43
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { formatLocator, parseLocator } from './parse-locator'
|
|
3
|
+
|
|
4
|
+
describe('parseLocator — accepted forms', () => {
|
|
5
|
+
test('host.tld/owner/repo/skill (monorepo)', () => {
|
|
6
|
+
const loc = parseLocator('github.com/anthropics/skills/skills/pdf')
|
|
7
|
+
expect(loc).toEqual({
|
|
8
|
+
raw: 'github.com/anthropics/skills/skills/pdf',
|
|
9
|
+
host: 'github.com',
|
|
10
|
+
owner: 'anthropics',
|
|
11
|
+
repo: 'skills',
|
|
12
|
+
skill: 'skills/pdf',
|
|
13
|
+
isLocalhost: false,
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('host.tld/owner/repo/skill (nested skill subpath)', () => {
|
|
18
|
+
const loc = parseLocator('github.com/mattpocock/skills/skills/engineering/tdd')
|
|
19
|
+
expect(loc?.skill).toBe('skills/engineering/tdd')
|
|
20
|
+
expect(loc?.repo).toBe('skills')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('host.tld/owner/repo/skill (flat repo, single skill segment)', () => {
|
|
24
|
+
const loc = parseLocator('github.com/daymade/claude-code-skills/skill-creator')
|
|
25
|
+
expect(loc?.skill).toBe('skill-creator')
|
|
26
|
+
expect(loc?.repo).toBe('claude-code-skills')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('host.tld/owner/repo (standalone — skill = null)', () => {
|
|
30
|
+
const loc = parseLocator('github.com/SpillwaveSolutions/design-doc-mermaid')
|
|
31
|
+
expect(loc).toEqual({
|
|
32
|
+
raw: 'github.com/SpillwaveSolutions/design-doc-mermaid',
|
|
33
|
+
host: 'github.com',
|
|
34
|
+
owner: 'SpillwaveSolutions',
|
|
35
|
+
repo: 'design-doc-mermaid',
|
|
36
|
+
skill: null,
|
|
37
|
+
isLocalhost: false,
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('host.tld/owner/repo/skill (arbitrary subdir name)', () => {
|
|
42
|
+
const loc = parseLocator('github.com/Cocoon-AI/architecture-diagram-generator/architecture-diagram')
|
|
43
|
+
expect(loc?.skill).toBe('architecture-diagram')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('localhost/<owner>/<repo> (canonical local skill, same shape as remote)', () => {
|
|
47
|
+
const loc = parseLocator('localhost/me/my-skill')
|
|
48
|
+
expect(loc).toEqual({
|
|
49
|
+
raw: 'localhost/me/my-skill',
|
|
50
|
+
host: 'localhost',
|
|
51
|
+
owner: 'me',
|
|
52
|
+
repo: 'my-skill',
|
|
53
|
+
skill: null,
|
|
54
|
+
isLocalhost: true,
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('non-github host accepted', () => {
|
|
59
|
+
const loc = parseLocator('gitlab.com/owner/repo/skill-x')
|
|
60
|
+
expect(loc?.host).toBe('gitlab.com')
|
|
61
|
+
expect(loc?.skill).toBe('skill-x')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('input is trimmed', () => {
|
|
65
|
+
const loc = parseLocator(' github.com/owner/repo ')
|
|
66
|
+
expect(loc?.repo).toBe('repo')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('parseLocator — rejected forms (per ADR-20260502012643244 FQ-only)', () => {
|
|
71
|
+
test('empty string', () => {
|
|
72
|
+
expect(parseLocator('')).toBeNull()
|
|
73
|
+
expect(parseLocator(' ')).toBeNull()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('single segment', () => {
|
|
77
|
+
expect(parseLocator('my-skill')).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('bare owner/repo (no host) is rejected — must be FQ', () => {
|
|
81
|
+
expect(parseLocator('daymade/claude-code-skills')).toBeNull()
|
|
82
|
+
expect(parseLocator('owner/repo/skill')).toBeNull()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('host without dot is treated as bare', () => {
|
|
86
|
+
expect(parseLocator('foo/bar/baz')).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('host.tld/owner without repo segment', () => {
|
|
90
|
+
expect(parseLocator('github.com/owner')).toBeNull()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('localhost with multi-segment path (skill subpath beyond owner/repo)', () => {
|
|
94
|
+
const loc = parseLocator('localhost/me/skills/my-skill')
|
|
95
|
+
expect(loc).toEqual({
|
|
96
|
+
raw: 'localhost/me/skills/my-skill',
|
|
97
|
+
host: 'localhost',
|
|
98
|
+
owner: 'me',
|
|
99
|
+
repo: 'skills',
|
|
100
|
+
skill: 'my-skill',
|
|
101
|
+
isLocalhost: true,
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('localhost alone (no owner/repo) is rejected', () => {
|
|
106
|
+
expect(parseLocator('localhost')).toBeNull()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('localhost/<name> (single name, missing repo) is rejected — that was the post-compaction agent invention', () => {
|
|
110
|
+
expect(parseLocator('localhost/my-skill')).toBeNull()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('formatLocator — round-trips', () => {
|
|
115
|
+
test.each([
|
|
116
|
+
'github.com/anthropics/skills/skills/pdf',
|
|
117
|
+
'github.com/SpillwaveSolutions/design-doc-mermaid',
|
|
118
|
+
'github.com/mattpocock/skills/skills/engineering/tdd',
|
|
119
|
+
'localhost/me/my-skill',
|
|
120
|
+
'localhost/me/skills/inner-skill',
|
|
121
|
+
])('round-trip: %s', (raw) => {
|
|
122
|
+
const parsed = parseLocator(raw)!
|
|
123
|
+
expect(formatLocator(parsed)).toBe(raw)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FQ-only locator parser, per ADR-20260502012643244.
|
|
3
|
+
*
|
|
4
|
+
* Three accepted forms (everything else returns null):
|
|
5
|
+
* - `host.tld/owner/repo[/skill]` — remote skill
|
|
6
|
+
* - `host.tld/owner/repo` — remote standalone (skill = null)
|
|
7
|
+
* - `localhost/owner/repo[/skill]` — local skill, same shape as remote
|
|
8
|
+
*
|
|
9
|
+
* The locator is a path. Appending it to the cold-pool base dir gives an
|
|
10
|
+
* existing directory; SKILL.md inside is the skill content. No special-case
|
|
11
|
+
* for localhost — only difference is `host === 'localhost'` signals "no
|
|
12
|
+
* remote, no clone, no refresh".
|
|
13
|
+
*
|
|
14
|
+
* Single-name `localhost/<name>` is rejected — that's a post-compaction
|
|
15
|
+
* agent invention, not the canonical form.
|
|
16
|
+
*/
|
|
17
|
+
import type { Locator } from './types.js'
|
|
18
|
+
|
|
19
|
+
export function parseLocator(input: string): Locator | null {
|
|
20
|
+
const trimmed = input.trim()
|
|
21
|
+
if (!trimmed) return null
|
|
22
|
+
|
|
23
|
+
const parts = trimmed.split('/').filter(Boolean)
|
|
24
|
+
// Need at least host/owner/repo (3 segments) for any FQ form
|
|
25
|
+
if (parts.length < 3) return null
|
|
26
|
+
|
|
27
|
+
// host segment: must be `localhost` (literal) or contain a `.` (host.tld)
|
|
28
|
+
const isLocalhost = parts[0] === 'localhost'
|
|
29
|
+
if (!isLocalhost && !parts[0].includes('.')) return null
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
raw: input,
|
|
33
|
+
host: parts[0],
|
|
34
|
+
owner: parts[1],
|
|
35
|
+
repo: parts[2],
|
|
36
|
+
skill: parts.length > 3 ? parts.slice(3).join('/') : null,
|
|
37
|
+
isLocalhost,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Recompose an FQ locator string from a parsed `Locator`. */
|
|
42
|
+
export function formatLocator(locator: Locator): string {
|
|
43
|
+
const base = `${locator.host}/${locator.owner}/${locator.repo}`
|
|
44
|
+
return locator.skill ? `${base}/${locator.skill}` : base
|
|
45
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for @lythos/cold-pool.
|
|
3
|
+
*
|
|
4
|
+
* Plan-shaped data structures for the intent/plan/execute pattern, plus
|
|
5
|
+
* the FQ Locator form per ADR-20260502012643244.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parsed FQ locator. Per ADR-20260502012643244, three forms only:
|
|
10
|
+
* - `host.tld/owner/repo[/skill]` (remote)
|
|
11
|
+
* - `host.tld/owner/repo` (remote standalone — repo root has SKILL.md, skill = null)
|
|
12
|
+
* - `localhost/owner/repo[/skill]` (no remote — same shape, host literal `localhost`)
|
|
13
|
+
*
|
|
14
|
+
* Bare names, shorthand `owner/repo`, and `localhost/<name>` (no owner/repo)
|
|
15
|
+
* are rejected by `parseLocator`. The locator is a path: appending to coldPool
|
|
16
|
+
* yields `<coldPool>/<host>/<owner>/<repo>[/skill]/SKILL.md`. The only thing
|
|
17
|
+
* `isLocalhost` controls is "no remote operations" (no clone, no pull, no fetch).
|
|
18
|
+
*/
|
|
19
|
+
export interface Locator {
|
|
20
|
+
readonly raw: string
|
|
21
|
+
readonly host: string
|
|
22
|
+
readonly owner: string
|
|
23
|
+
readonly repo: string
|
|
24
|
+
readonly skill: string | null
|
|
25
|
+
readonly isLocalhost: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Output of `buildValidationPlan`. Pure data.
|
|
30
|
+
*
|
|
31
|
+
* Validation has no separate execute step — the report itself is the plan.
|
|
32
|
+
* Agents and CLI surfaces consume this directly to render structured
|
|
33
|
+
* diagnostics (per ADR-20260507014124191).
|
|
34
|
+
*/
|
|
35
|
+
export interface ValidationReport {
|
|
36
|
+
readonly status: 'valid' | 'invalid' | 'ambiguous'
|
|
37
|
+
readonly locator: string
|
|
38
|
+
readonly phase: 'syntax' | 'repo-existence' | 'path-existence' | 'skill-md-existence'
|
|
39
|
+
readonly findings: ValidationFindings
|
|
40
|
+
readonly suggestedFixes: ReadonlyArray<SuggestedFix>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ValidationFindings {
|
|
44
|
+
readonly parseError?: string
|
|
45
|
+
readonly repoExists?: boolean
|
|
46
|
+
readonly repoIsPrivate?: boolean
|
|
47
|
+
readonly skillMdFound?: boolean
|
|
48
|
+
/** Subdirectories of the cloned repo that contain a SKILL.md, when path-existence fails. */
|
|
49
|
+
readonly detectedPaths?: ReadonlyArray<string>
|
|
50
|
+
/** HTTP status from the remote check, if any. */
|
|
51
|
+
readonly remoteStatus?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SuggestedFix {
|
|
55
|
+
readonly action: 'update-locator' | 'web-search' | 'prompt-user'
|
|
56
|
+
/** 0..1 — caller decides whether to act unattended. */
|
|
57
|
+
readonly confidence: number
|
|
58
|
+
readonly message: string
|
|
59
|
+
readonly newLocator?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Per-locator fetch plan. Pure data; no side effects. */
|
|
63
|
+
export interface FetchPlan {
|
|
64
|
+
readonly locator: Locator
|
|
65
|
+
readonly cloneUrl: string
|
|
66
|
+
readonly targetDir: string
|
|
67
|
+
readonly ref?: string
|
|
68
|
+
/** When true, executor should skip the clone (idempotent fetch). */
|
|
69
|
+
readonly alreadyExists: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface FetchResult {
|
|
73
|
+
readonly status: 'cloned' | 'already-present' | 'failed'
|
|
74
|
+
readonly targetDir: string
|
|
75
|
+
readonly message?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Injectable IO for `executeFetchPlan`. Test swaps in mocks. */
|
|
79
|
+
export interface FetchIO {
|
|
80
|
+
gitClone?: (url: string, dir: string, opts?: { depth?: number; ref?: string }) => void
|
|
81
|
+
exists?: (path: string) => boolean
|
|
82
|
+
log?: (msg: string) => void
|
|
83
|
+
}
|