skillflow-ai 0.0.0-stage → 0.1.0
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 +34 -2
- package/bin/skillflow.mjs +194 -0
- package/lib/unzip.mjs +52 -0
- package/package.json +11 -4
package/README.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
-
#
|
|
1
|
+
# skillflow-ai
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Install [SkillFlow AI](https://directory.skillflow.ai.id) skills into Claude Code, Codex, or any agent's skills folder with
|
|
4
|
+
one command, and keep them up to date.
|
|
5
|
+
|
|
6
|
+
Needs a SkillFlow **Pro** account and Node.js 18 or newer.
|
|
7
|
+
|
|
8
|
+
## Start
|
|
9
|
+
|
|
10
|
+
1. Open the SkillFlow directory, go to **CLI token**, and make a token (it is shown once).
|
|
11
|
+
2. On your computer:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx skillflow-ai login
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Paste the token. It is saved in `~/.skillflow/config.json`, readable only by you.
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx skillflow-ai list # skills you can install
|
|
23
|
+
npx skillflow-ai add carousel-ringkasan-buku # into ~/.claude/skills
|
|
24
|
+
npx skillflow-ai add <slug> --agent codex # into ~/.codex/skills
|
|
25
|
+
npx skillflow-ai add <slug> --project # into ./.claude/skills of this folder
|
|
26
|
+
npx skillflow-ai add <slug> --dir <folder> # any folder (Antigravity and others)
|
|
27
|
+
npx skillflow-ai update # update everything you installed
|
|
28
|
+
npx skillflow-ai remove <slug>
|
|
29
|
+
npx skillflow-ai whoami
|
|
30
|
+
npx skillflow-ai logout
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
If you have a brand profile on the directory, every skill you add carries it as `BRAND.md`.
|
|
34
|
+
|
|
35
|
+
The token stops working when your account leaves Pro, or when you revoke it on the directory.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SkillFlow CLI: install SkillFlow skills into Claude Code, Codex or any folder with one command.
|
|
3
|
+
// Needs a personal token from the SkillFlow directory (Pro). No dependencies; Node 18+.
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, renameSync, chmodSync } from 'node:fs'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { join, resolve, dirname } from 'node:path'
|
|
7
|
+
import { createInterface } from 'node:readline'
|
|
8
|
+
import { readZip } from '../lib/unzip.mjs'
|
|
9
|
+
|
|
10
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
11
|
+
const HOME = process.env.SKILLFLOW_HOME || join(homedir(), '.skillflow')
|
|
12
|
+
const CONFIG = join(HOME, 'config.json')
|
|
13
|
+
const INSTALLED = join(HOME, 'installed.json')
|
|
14
|
+
const DEFAULT_SERVER = 'https://directory.skillflow.ai.id'
|
|
15
|
+
const AGENTS = { claude: join(homedir(), '.claude', 'skills'), codex: join(homedir(), '.codex', 'skills') }
|
|
16
|
+
const PROJECT = { claude: join('.claude', 'skills'), codex: join('.codex', 'skills') }
|
|
17
|
+
|
|
18
|
+
const say = (...a) => console.log(...a)
|
|
19
|
+
const fail = (msg, code = 1) => { console.error('✗ ' + msg); process.exit(code) }
|
|
20
|
+
|
|
21
|
+
function readJson(file, fallback) {
|
|
22
|
+
try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return fallback }
|
|
23
|
+
}
|
|
24
|
+
function writePrivate(file, data) {
|
|
25
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
|
|
26
|
+
writeFileSync(file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
|
|
27
|
+
try { chmodSync(file, 0o600) } catch {}
|
|
28
|
+
}
|
|
29
|
+
const config = () => readJson(CONFIG, {})
|
|
30
|
+
const server = () => (process.env.SKILLFLOW_SERVER || config().server || DEFAULT_SERVER).replace(/\/+$/, '')
|
|
31
|
+
|
|
32
|
+
function parse(argv) {
|
|
33
|
+
const args = []; const opts = {}
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const a = argv[i]
|
|
36
|
+
if (a.startsWith('--')) {
|
|
37
|
+
const [k, v] = a.slice(2).split('=')
|
|
38
|
+
if (v !== undefined) opts[k] = v
|
|
39
|
+
else if (argv[i + 1] && !argv[i + 1].startsWith('-') && ['agent', 'dir', 'server', 'kind', 'token'].includes(k)) opts[k] = argv[++i]
|
|
40
|
+
else opts[k] = true
|
|
41
|
+
} else if (a === '-h') opts.help = true
|
|
42
|
+
else args.push(a)
|
|
43
|
+
}
|
|
44
|
+
return { args, opts }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function api(path, { raw = false, token = config().token } = {}) {
|
|
48
|
+
if (!token) fail('Not logged in. Make a token on the SkillFlow directory (Pro), then run: npx skillflow-ai login')
|
|
49
|
+
let res
|
|
50
|
+
try {
|
|
51
|
+
res = await fetch(server() + path, { headers: { Authorization: 'Bearer ' + token, Accept: raw ? 'application/zip' : 'application/json', 'User-Agent': 'skillflow-cli/' + VERSION } })
|
|
52
|
+
} catch (e) { fail('Could not reach ' + server() + ' (' + e.message + ').') }
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
let body = {}
|
|
55
|
+
try { body = await res.json() } catch {}
|
|
56
|
+
fail(body.message || `The server answered ${res.status}.`, res.status === 401 || res.status === 403 ? 2 : 1)
|
|
57
|
+
}
|
|
58
|
+
return raw ? { buf: Buffer.from(await res.arrayBuffer()), version: res.headers.get('x-skillflow-version') } : res.json()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ask(question) {
|
|
62
|
+
return new Promise(done => {
|
|
63
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
64
|
+
rl.question(question, a => { rl.close(); done(a.trim()) })
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function targetDir(opts) {
|
|
69
|
+
if (opts.dir && opts.dir !== true) return resolve(opts.dir)
|
|
70
|
+
const agent = (opts.agent && opts.agent !== true ? opts.agent : 'claude').toLowerCase()
|
|
71
|
+
if (opts.project) {
|
|
72
|
+
if (!PROJECT[agent]) fail(`For --agent ${agent} use --dir <folder>.`)
|
|
73
|
+
return resolve(PROJECT[agent])
|
|
74
|
+
}
|
|
75
|
+
if (!AGENTS[agent]) fail(`No default folder for --agent ${agent}. Use --dir <folder> (the agent's skills folder).`)
|
|
76
|
+
return AGENTS[agent]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function install(slug, dir) {
|
|
80
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) fail(`"${slug}" is not a skill name.`)
|
|
81
|
+
const { buf, version } = await api(`/api/skills/v1/skills/${slug}/bundle`, { raw: true })
|
|
82
|
+
const entries = readZip(buf)
|
|
83
|
+
// Bundles hold "<slug>/…". Anything outside that folder is ignored.
|
|
84
|
+
const own = entries.filter(e => e.name === slug + '/' || e.name.startsWith(slug + '/'))
|
|
85
|
+
if (!own.some(e => !e.dir)) fail(`The bundle for ${slug} is empty.`)
|
|
86
|
+
mkdirSync(dir, { recursive: true })
|
|
87
|
+
const final = join(dir, slug)
|
|
88
|
+
const tmp = join(dir, `.${slug}.skillflow-tmp-${process.pid}`)
|
|
89
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
90
|
+
for (const e of own) {
|
|
91
|
+
const path = join(tmp, e.name.slice(slug.length + 1))
|
|
92
|
+
if (!resolve(path).startsWith(resolve(tmp))) fail(`unsafe path in bundle: ${e.name}`)
|
|
93
|
+
if (e.dir) { mkdirSync(path, { recursive: true }); continue }
|
|
94
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
95
|
+
writeFileSync(path, e.data)
|
|
96
|
+
}
|
|
97
|
+
// Swap in one step, so a failed download never leaves a half-written skill.
|
|
98
|
+
rmSync(final, { recursive: true, force: true })
|
|
99
|
+
renameSync(tmp, final)
|
|
100
|
+
const list = readJson(INSTALLED, {})
|
|
101
|
+
list[final] = { slug, version: version || null, dir, at: new Date().toISOString() }
|
|
102
|
+
writePrivate(INSTALLED, list)
|
|
103
|
+
return { final, version }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const HELP = `skillflow ${VERSION}: install SkillFlow skills into your AI agent
|
|
107
|
+
|
|
108
|
+
npx skillflow-ai login [--token sf_…] [--server URL] save your personal token (make it on the directory, Pro)
|
|
109
|
+
npx skillflow-ai whoami who the token belongs to
|
|
110
|
+
npx skillflow-ai list [--kind video] skills you can install
|
|
111
|
+
npx skillflow-ai add <slug> [<slug>…] install into ~/.claude/skills
|
|
112
|
+
--agent codex ~/.codex/skills instead
|
|
113
|
+
--project ./.claude/skills (or ./.codex/skills) in this folder
|
|
114
|
+
--dir <folder> any folder (Antigravity and other agents)
|
|
115
|
+
npx skillflow-ai update [<slug>] update installed skills to the latest version
|
|
116
|
+
npx skillflow-ai remove <slug> [--dir <folder>] delete an installed skill
|
|
117
|
+
npx skillflow-ai logout forget the token on this computer
|
|
118
|
+
`
|
|
119
|
+
|
|
120
|
+
async function main() {
|
|
121
|
+
const [cmd, ...rest] = process.argv.slice(2)
|
|
122
|
+
const { args, opts } = parse(rest)
|
|
123
|
+
switch (cmd) {
|
|
124
|
+
case 'login': {
|
|
125
|
+
const token = opts.token && opts.token !== true ? opts.token : await ask('Paste your SkillFlow token: ')
|
|
126
|
+
if (!/^sf_[A-Za-z0-9]{20,}$/.test(token)) fail('That does not look like a SkillFlow token (it starts with sf_).')
|
|
127
|
+
const base = opts.server && opts.server !== true ? opts.server.replace(/\/+$/, '') : undefined
|
|
128
|
+
if (base) process.env.SKILLFLOW_SERVER = base
|
|
129
|
+
const me = await api('/api/skills/v1/me', { token })
|
|
130
|
+
writePrivate(CONFIG, { ...config(), token, ...(base ? { server: base } : {}) })
|
|
131
|
+
say(`✓ Logged in as ${me.name} (${me.plan}). Token saved in ${CONFIG}.`)
|
|
132
|
+
if (me.brand_profile) say(` Skills you add carry your brand profile "${me.brand_profile}" as BRAND.md.`)
|
|
133
|
+
break
|
|
134
|
+
}
|
|
135
|
+
case 'logout': {
|
|
136
|
+
const c = config(); delete c.token; writePrivate(CONFIG, c)
|
|
137
|
+
say('✓ Token removed from this computer. (Revoke it on the directory too if it may have leaked.)')
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
case 'whoami': {
|
|
141
|
+
const me = await api('/api/skills/v1/me')
|
|
142
|
+
say(`${me.name} · ${me.plan}${me.brand_profile ? ` · brand profile: ${me.brand_profile}` : ''} · ${server()}`)
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
case 'list': {
|
|
146
|
+
const { skills } = await api('/api/skills/v1/catalog')
|
|
147
|
+
const installed = new Set(Object.values(readJson(INSTALLED, {})).map(r => r.slug))
|
|
148
|
+
const rows = skills.filter(s => s.installable && (!opts.kind || s.kind === opts.kind))
|
|
149
|
+
for (const s of rows) say(`${installed.has(s.slug) ? '✓' : ' '} ${s.slug.padEnd(42)} ${s.kind.padEnd(12)} v${s.version} ${s.title}`)
|
|
150
|
+
say(`\n${rows.length} skill(s). Install one with: npx skillflow-ai add <slug>`)
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
case 'add': {
|
|
154
|
+
if (!args.length) fail('Name a skill: npx skillflow-ai add <slug>')
|
|
155
|
+
const dir = targetDir(opts)
|
|
156
|
+
for (const slug of args) {
|
|
157
|
+
const r = await install(slug, dir)
|
|
158
|
+
say(`✓ ${slug}${r.version ? ' v' + r.version : ''} → ${r.final}`)
|
|
159
|
+
}
|
|
160
|
+
break
|
|
161
|
+
}
|
|
162
|
+
case 'update': {
|
|
163
|
+
const list = readJson(INSTALLED, {})
|
|
164
|
+
const want = Object.entries(list).filter(([, r]) => !args.length || args.includes(r.slug))
|
|
165
|
+
if (!want.length) { say('Nothing installed yet.'); break }
|
|
166
|
+
const { skills } = await api('/api/skills/v1/catalog')
|
|
167
|
+
const latest = Object.fromEntries(skills.map(s => [s.slug, s.version]))
|
|
168
|
+
let n = 0
|
|
169
|
+
for (const [, r] of want) {
|
|
170
|
+
if (!latest[r.slug]) { say(`- ${r.slug}: no longer in the catalog (left as it is)`); continue }
|
|
171
|
+
if (latest[r.slug] === r.version && !opts.force) continue
|
|
172
|
+
const res = await install(r.slug, r.dir)
|
|
173
|
+
say(`✓ ${r.slug} ${r.version || '?'} → ${res.version || latest[r.slug]}`); n++
|
|
174
|
+
}
|
|
175
|
+
say(n ? `${n} skill(s) updated.` : 'Everything is up to date.')
|
|
176
|
+
break
|
|
177
|
+
}
|
|
178
|
+
case 'remove': {
|
|
179
|
+
if (!args.length) fail('Name a skill: npx skillflow-ai remove <slug>')
|
|
180
|
+
const list = readJson(INSTALLED, {})
|
|
181
|
+
for (const slug of args) {
|
|
182
|
+
const keys = Object.keys(list).filter(k => list[k].slug === slug && (!opts.dir || list[k].dir === resolve(opts.dir)))
|
|
183
|
+
if (!keys.length) { say(`- ${slug}: not installed by skillflow`); continue }
|
|
184
|
+
for (const k of keys) { rmSync(k, { recursive: true, force: true }); delete list[k]; say(`✓ removed ${k}`) }
|
|
185
|
+
}
|
|
186
|
+
writePrivate(INSTALLED, list)
|
|
187
|
+
break
|
|
188
|
+
}
|
|
189
|
+
case '--version': case '-v': case 'version': say(VERSION); break
|
|
190
|
+
default: say(HELP); if (cmd && cmd !== 'help' && cmd !== '--help') process.exit(1)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
main().catch(e => fail(e.message))
|
package/lib/unzip.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Minimal ZIP reader (stored + deflate), enough for SkillFlow skill bundles. No dependencies.
|
|
2
|
+
// Every entry name is checked before it becomes a path: no absolute paths, no "..", no drive letters.
|
|
3
|
+
import { inflateRawSync } from 'node:zlib'
|
|
4
|
+
|
|
5
|
+
const EOCD = 0x06054b50
|
|
6
|
+
const CEN = 0x02014b50
|
|
7
|
+
const LOC = 0x04034b50
|
|
8
|
+
|
|
9
|
+
/** @returns {{name: string, dir: boolean, data: Buffer}[]} */
|
|
10
|
+
export function readZip(buf) {
|
|
11
|
+
let eocd = -1
|
|
12
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i--) {
|
|
13
|
+
if (buf.readUInt32LE(i) === EOCD) { eocd = i; break }
|
|
14
|
+
}
|
|
15
|
+
if (eocd < 0) throw new Error('not a ZIP file')
|
|
16
|
+
const count = buf.readUInt16LE(eocd + 10)
|
|
17
|
+
let p = buf.readUInt32LE(eocd + 16)
|
|
18
|
+
const out = []
|
|
19
|
+
for (let n = 0; n < count; n++) {
|
|
20
|
+
if (buf.readUInt32LE(p) !== CEN) throw new Error('broken ZIP directory')
|
|
21
|
+
const method = buf.readUInt16LE(p + 10)
|
|
22
|
+
const csize = buf.readUInt32LE(p + 20)
|
|
23
|
+
const nameLen = buf.readUInt16LE(p + 28)
|
|
24
|
+
const extraLen = buf.readUInt16LE(p + 30)
|
|
25
|
+
const commentLen = buf.readUInt16LE(p + 32)
|
|
26
|
+
const local = buf.readUInt32LE(p + 42)
|
|
27
|
+
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8')
|
|
28
|
+
p += 46 + nameLen + extraLen + commentLen
|
|
29
|
+
|
|
30
|
+
if (buf.readUInt32LE(local) !== LOC) throw new Error('broken ZIP entry')
|
|
31
|
+
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28)
|
|
32
|
+
const raw = buf.subarray(start, start + csize)
|
|
33
|
+
const dir = name.endsWith('/')
|
|
34
|
+
let data = Buffer.alloc(0)
|
|
35
|
+
if (!dir) {
|
|
36
|
+
if (method === 0) data = Buffer.from(raw)
|
|
37
|
+
else if (method === 8) data = inflateRawSync(raw)
|
|
38
|
+
else throw new Error(`unsupported compression in ${name}`)
|
|
39
|
+
}
|
|
40
|
+
out.push({ name: safeName(name), dir, data })
|
|
41
|
+
}
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A relative POSIX path, or an error. */
|
|
46
|
+
export function safeName(name) {
|
|
47
|
+
const n = name.replace(/\\/g, '/')
|
|
48
|
+
if (!n || n.startsWith('/') || /^[a-zA-Z]:/.test(n) || n.split('/').some(part => part === '..')) {
|
|
49
|
+
throw new Error(`unsafe path in ZIP: ${name}`)
|
|
50
|
+
}
|
|
51
|
+
return n
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillflow-ai",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Install SkillFlow AI skills into Claude Code, Codex or any agent folder with one command.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "skillflow-ai": "bin/skillflow.mjs", "skillflow": "bin/skillflow.mjs" },
|
|
7
|
+
"files": ["bin", "lib", "README.md"],
|
|
8
|
+
"engines": { "node": ">=18" },
|
|
9
|
+
"keywords": ["skillflow", "claude-code", "codex", "ai-agent", "skills"],
|
|
10
|
+
"homepage": "https://directory.skillflow.ai.id",
|
|
11
|
+
"license": "UNLICENSED",
|
|
12
|
+
"scripts": { "test": "node test/unzip.test.mjs" }
|
|
13
|
+
}
|