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 CHANGED
@@ -1,3 +1,35 @@
1
- # Temporary Holding Version
1
+ # skillflow-ai
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }