@dastyaresocial/ds-cs 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 +134 -2
- package/bin/ds-cs.mjs +14 -0
- package/package.json +33 -4
- package/skills/README.md +71 -0
- package/skills/dev/add-component/SKILL.md +166 -0
- package/skills/dev/add-orpc-endpoint/SKILL.md +143 -0
- package/skills/dev/db-migration/SKILL.md +104 -0
- package/skills/dev/release/SKILL.md +106 -0
- package/skills/product/brand-config/SKILL.md +98 -0
- package/skills/product/manage-stories/SKILL.md +119 -0
- package/skills/product/mcp-connect/SKILL.md +116 -0
- package/skills/product/post-with-media/SKILL.md +197 -0
- package/skills/product/self-host-deploy/SKILL.md +112 -0
- package/skills/product/webhooks/SKILL.md +160 -0
- package/src/agents.mjs +130 -0
- package/src/args.mjs +111 -0
- package/src/auth.mjs +82 -0
- package/src/commands/add.mjs +118 -0
- package/src/commands/auth.mjs +87 -0
- package/src/commands/init.mjs +106 -0
- package/src/commands/list.mjs +89 -0
- package/src/commands/mcp.mjs +101 -0
- package/src/commands/remove.mjs +80 -0
- package/src/commands/search.mjs +51 -0
- package/src/commands/target.mjs +83 -0
- package/src/commands/update.mjs +94 -0
- package/src/index.mjs +176 -0
- package/src/install.mjs +174 -0
- package/src/lockfile.mjs +74 -0
- package/src/mcp-config.mjs +39 -0
- package/src/registry/archive.mjs +209 -0
- package/src/registry/fetch.mjs +114 -0
- package/src/registry/sources.mjs +395 -0
- package/src/skills/discover.mjs +170 -0
- package/src/skills/frontmatter.mjs +85 -0
- package/src/utils/errors.mjs +13 -0
- package/src/utils/fs.mjs +106 -0
- package/src/utils/log.mjs +85 -0
- package/src/utils/prompt.mjs +69 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: self-host-deploy
|
|
3
|
+
description: Install, run, update and back up a self-hosted DS-CS channel with Docker Compose or Vercel. Use for the one-command installer, env var setup, reverse proxying, port layout, backing up the database, or moving a channel to a new version. For writing content use post-with-media.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Self-hosting DS-CS
|
|
7
|
+
|
|
8
|
+
## What you need
|
|
9
|
+
|
|
10
|
+
- Docker and curl
|
|
11
|
+
- A domain with HTTPS (required for browser push notifications)
|
|
12
|
+
- Ports: `8729` (app), `9000` / `9001` (S3 console)
|
|
13
|
+
|
|
14
|
+
The stack ships Postgres 16 and rustfs (S3-compatible storage) alongside the app. You do not need to provision either.
|
|
15
|
+
|
|
16
|
+
## Option A — one command
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
curl -fsSL https://raw.githubusercontent.com/dastyare-social/DS-CS/main/scripts/install.sh | bash
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Into a specific directory:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
curl -fsSL https://raw.githubusercontent.com/dastyare-social/DS-CS/main/scripts/install.sh -o install.sh
|
|
26
|
+
bash install.sh /opt/ds-cs
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The script downloads `docker-compose.yml`, drops in the three editable brand files, creates `.env` with generated secrets if none exists, and starts the stack. It is non-destructive: existing files are left intact rather than overwritten. Compose runs under the pinned project name `ds-cs`, so containers and volumes are prefixed `ds-cs-` regardless of the install directory — two installs cannot collide on volume names.
|
|
30
|
+
|
|
31
|
+
**Review the generated `.env` before using this in production.** The secrets it writes are locally safe, not production-safe.
|
|
32
|
+
|
|
33
|
+
## Option B — compose by hand
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
curl -fsSL https://raw.githubusercontent.com/dastyare-social/DS-CS/main/docker-compose.yml -o docker-compose.yml
|
|
37
|
+
cp .env.example .env
|
|
38
|
+
$EDITOR .env
|
|
39
|
+
docker compose up -d
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The app container runs migrations and bootstraps the admin user automatically on boot. That is why `db:migrate` runs before `next build` in the build script — schema changes ship as files, never as live database edits.
|
|
43
|
+
|
|
44
|
+
## Environment variables
|
|
45
|
+
|
|
46
|
+
| Variable | Purpose |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `DATABASE_URL` | Postgres connection |
|
|
49
|
+
| `API_KEY` | Shared key for REST writes and MCP write tools |
|
|
50
|
+
| `MCP_API_KEY` | Optional separate key for the stdio MCP server |
|
|
51
|
+
| `BETTER_AUTH_URL`, `BETTER_AUTH_SECRET` | Auth |
|
|
52
|
+
| `ADMIN_EMAIL`, `ADMIN_PASSWORD` | Bootstrap admin, used once |
|
|
53
|
+
| `S3_ENDPOINT`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BUCKET_NAME`, `S3_FORCE_PATH_STYLE` | Media storage |
|
|
54
|
+
| `S3_PUBLIC_BASE_URL` | Browser-reachable base for media URLs — keep in sync with the published rustfs port |
|
|
55
|
+
| `NEXT_PUBLIC_APP_URL` | Public URL; drives sitemap, canonical URLs and OG images |
|
|
56
|
+
| `NEXT_PUBLIC_WEBPUSH_PUBLIC_KEY`, `WEBPUSH_PRIVATE_KEY`, `WEBPUSH_SUBJECT` | Web Push (VAPID) |
|
|
57
|
+
| `NEXT_PUBLIC_ALLOW_INDEXING` | Unblocks search engines; default blocks indexing |
|
|
58
|
+
| `MEDIA_MAX_*_SIZE_MB`, `MEDIA_ALLOWED_MIME_TYPES` | Upload limits |
|
|
59
|
+
| `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN`, `POSTHOG_API_KEY` | Analytics |
|
|
60
|
+
|
|
61
|
+
`NEXT_PUBLIC_APP_URL` being wrong is the most common self-hosting mistake: the sitemap, canonical URLs and OG images are all built from it, so they silently point at localhost.
|
|
62
|
+
|
|
63
|
+
## Reverse proxy
|
|
64
|
+
|
|
65
|
+
Terminate TLS at Caddy, nginx or Traefik and proxy to port `8729`. Push notifications require HTTPS — the Service Worker does not register on plain HTTP outside `localhost`.
|
|
66
|
+
|
|
67
|
+
Also proxy or expose the S3 port if media URLs should be served from your own domain; then set `S3_PUBLIC_BASE_URL` to match. Media served from an unconfigured domain falls back to unoptimized images, which is slower but not broken.
|
|
68
|
+
|
|
69
|
+
## Vercel
|
|
70
|
+
|
|
71
|
+
The `Dockerfile.vercel` in the install is a pull-only blueprint (FROM `dastyaresocial/ds-cs:latest` with a PORT-aware CMD) so an installed folder can deploy to Vercel Fluid compute without the app source. Configure the same env vars there. Remember the database and S3 are external services — Vercel does not run your Postgres or rustfs.
|
|
72
|
+
|
|
73
|
+
## Updating an existing install
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
cd /opt/ds-cs
|
|
77
|
+
docker compose pull
|
|
78
|
+
docker compose up -d
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Migrations run on boot. Config files are bind-mounted and read per request, so a config edit does not need an update at all.
|
|
82
|
+
|
|
83
|
+
## Backups
|
|
84
|
+
|
|
85
|
+
Two things matter and they live in different places:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# database
|
|
89
|
+
docker compose exec -T db pg_dump -U postgres ds-cs > backup.sql
|
|
90
|
+
|
|
91
|
+
# media
|
|
92
|
+
docker run --rm -v ds-cs_rustfs_data:/data -v "$PWD:/backup" alpine \
|
|
93
|
+
tar czf /backup/media.tar.gz -C /data .
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The media backup volume name follows the pinned project name — confirm it with `docker volume ls | grep rustfs` before assuming. Losing media is not recoverable by re-running the installer; it is the one backup that actually matters.
|
|
97
|
+
|
|
98
|
+
Restore:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
docker compose exec -T db psql -U postgres ds-cs < backup.sql
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Verify
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
docker compose ps
|
|
108
|
+
curl -s http://localhost:8729/api/posts?type=count
|
|
109
|
+
curl -s https://your-domain.com/.well-known/mcp
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Open the channel, confirm the feed loads, then confirm a media upload round-trips — that exercises the app, Postgres and S3 together.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: webhooks
|
|
3
|
+
description: Register, inspect and verify signed webhook deliveries from a DS-CS channel. Use for reacting to post and story events, debugging a failing endpoint, or verifying the x-ds-webhook-signature HMAC. For posting content use post-with-media.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Webhooks
|
|
7
|
+
|
|
8
|
+
## What it is for
|
|
9
|
+
|
|
10
|
+
A channel POSTs a signed JSON body to a URL you register whenever something happens. Use it to mirror content into a newsletter, a Discord channel, a cache, or another service — anything that should react to activity without polling.
|
|
11
|
+
|
|
12
|
+
## Register an endpoint
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
curl -X POST https://cs.example.com/api/webhooks \
|
|
16
|
+
-H "Authorization: Bearer $API_KEY" \
|
|
17
|
+
-H "Content-Type: application/json" \
|
|
18
|
+
-d '{
|
|
19
|
+
"url": "https://my-app.com/hooks/ds-cs",
|
|
20
|
+
"events": ["post.created", "post.updated", "post.deleted"]
|
|
21
|
+
}'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The response contains a **per-endpoint HMAC secret, returned only once**. Store it immediately — it cannot be read back in plaintext later.
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"id": "…",
|
|
29
|
+
"url": "https://my-app.com/hooks/ds-cs",
|
|
30
|
+
"events": ["post.created"],
|
|
31
|
+
"secret": "whsec_…"
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Events
|
|
36
|
+
|
|
37
|
+
| Group | Events |
|
|
38
|
+
|---|---|
|
|
39
|
+
| Posts | `post.created`, `post.updated`, `post.deleted`, `post.reacted`, `post.viewed` |
|
|
40
|
+
| Stories | `story.created`, `story.updated`, `story.deleted`, `story.viewed`, `story.liked` |
|
|
41
|
+
|
|
42
|
+
Subscribe only to what you use — every event is a delivery, and each one is an outbound HTTP request from your channel.
|
|
43
|
+
|
|
44
|
+
## Delivery payload
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"id": "<delivery id>",
|
|
49
|
+
"event": "post.created",
|
|
50
|
+
"timestamp": "2026-08-23T10:15:00.000Z",
|
|
51
|
+
"data": { "…": "the post or story record" }
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`data` carries the affected record. Verify the signature **before** parsing it.
|
|
56
|
+
|
|
57
|
+
## Verify the signature — do not skip this
|
|
58
|
+
|
|
59
|
+
Every delivery has:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
x-ds-webhook-signature: t=<unix>,v1=<hex>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`t` is a unix timestamp, `v1` is a lowercase hex HMAC-SHA256. The signed message is the timestamp, a dot, and the **raw request body** — not the parsed JSON:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
69
|
+
|
|
70
|
+
export async function POST(request) {
|
|
71
|
+
const header = request.headers.get("x-ds-webhook-signature") ?? "";
|
|
72
|
+
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header);
|
|
73
|
+
if (!match) return new Response("bad signature header", { status: 401 });
|
|
74
|
+
|
|
75
|
+
const [, timestamp, provided] = match;
|
|
76
|
+
|
|
77
|
+
// Reject replays: anything older than five minutes is not a fresh delivery.
|
|
78
|
+
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
|
|
79
|
+
if (age > 300) return new Response("stale delivery", { status: 401 });
|
|
80
|
+
|
|
81
|
+
// The raw body — read as text BEFORE any JSON.parse.
|
|
82
|
+
const raw = await request.text();
|
|
83
|
+
|
|
84
|
+
const expected = createHmac("sha256", process.env.DS_CS_WEBHOOK_SECRET)
|
|
85
|
+
.update(`${timestamp}.${raw}`)
|
|
86
|
+
.digest("hex");
|
|
87
|
+
|
|
88
|
+
const a = Buffer.from(expected, "hex");
|
|
89
|
+
const b = Buffer.from(provided, "hex");
|
|
90
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
91
|
+
return new Response("invalid signature", { status: 401 });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const payload = JSON.parse(raw);
|
|
95
|
+
// … handle it
|
|
96
|
+
return new Response("ok", { status: 200 });
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Four things that break naive verification:
|
|
101
|
+
|
|
102
|
+
1. **Sign the raw body.** Re-serializing with `JSON.stringify` changes key order and whitespace, so the HMAC will not match.
|
|
103
|
+
2. **Use the raw request body**, not a framework-parsed object. In Next.js, call `await request.text()` — once. Calling `request.json()` first consumes the stream.
|
|
104
|
+
3. **Compare with `timingSafeEqual`.** A plain `===` leaks the signature byte by byte.
|
|
105
|
+
4. **Check the timestamp.** Without a freshness check, a captured delivery can be replayed forever.
|
|
106
|
+
|
|
107
|
+
The secret is **per endpoint**, not the channel `API_KEY`. The two are unrelated and never interchangeable.
|
|
108
|
+
|
|
109
|
+
## Retries and delivery status
|
|
110
|
+
|
|
111
|
+
Failed deliveries are retried up to three times with backoff. The final failure is recorded.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
GET /api/webhooks # list, with lastStatus / lastAttemptAt / failureCount
|
|
115
|
+
GET /api/webhooks/{webhook_id} # one endpoint, with delivery status
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`failureCount` climbing is the signal to check: is your endpoint returning 2xx fast, or timing out?
|
|
119
|
+
|
|
120
|
+
## Manage
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
# change URL, events, or pause
|
|
124
|
+
curl -X PATCH https://cs.example.com/api/webhooks/$ID \
|
|
125
|
+
-H "Authorization: Bearer $API_KEY" \
|
|
126
|
+
-H "Content-Type: application/json" \
|
|
127
|
+
-d '{"events":["post.created","story.created"],"active":true}'
|
|
128
|
+
|
|
129
|
+
# pause without deleting
|
|
130
|
+
curl -X PATCH https://cs.example.com/api/webhooks/$ID \
|
|
131
|
+
-H "Authorization: Bearer $API_KEY" \
|
|
132
|
+
-H "Content-Type: application/json" \
|
|
133
|
+
-d '{"active":false}'
|
|
134
|
+
|
|
135
|
+
# delete permanently
|
|
136
|
+
curl -X DELETE https://cs.example.com/api/webhooks/$ID \
|
|
137
|
+
-H "Authorization: Bearer $API_KEY"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Pausing (`active: false`) stops deliveries but keeps the endpoint and its secret. Deleting removes it and the secret with it.
|
|
141
|
+
|
|
142
|
+
## Verify locally before going live
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
ngrok http 3000 # or any tunnel
|
|
146
|
+
# register the https URL
|
|
147
|
+
curl -X POST https://cs.example.com/api/webhooks/$ID \
|
|
148
|
+
-H "Authorization: Bearer $API_KEY" -d '{"events":["post.created"]}'
|
|
149
|
+
# then create a post and watch the tunnel log
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Confirm your handler returns 2xx quickly. Slow handlers exhaust the retry budget.
|
|
153
|
+
|
|
154
|
+
## Mistakes that cost time
|
|
155
|
+
|
|
156
|
+
- Verifying against the channel `API_KEY` instead of the endpoint secret.
|
|
157
|
+
- `JSON.parse` before `request.text()`, leaving nothing to verify.
|
|
158
|
+
- Skipping the timestamp check and leaving the endpoint replayable.
|
|
159
|
+
- Registering `*.*` and drowning in `post.viewed` deliveries.
|
|
160
|
+
- Treating a 4xx as retryable noise. A 4xx means your handler rejected the event; it will not succeed on retry. Return 2xx for anything you have durably queued, and process asynchronously.
|
package/src/agents.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { CliError } from "./utils/errors.mjs";
|
|
6
|
+
import { exists, isDirectory } from "./utils/fs.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where each supported agent looks for project-scoped skills.
|
|
10
|
+
*
|
|
11
|
+
* Many agents share `.agents/skills/`, so they are listed as separate names but
|
|
12
|
+
* resolve to the same directory — installing to one installs to all of them.
|
|
13
|
+
* This mirrors `npx skills`; entries not listed here can still be targeted with
|
|
14
|
+
* `--agent-dir <path>`.
|
|
15
|
+
*/
|
|
16
|
+
export const AGENTS = [
|
|
17
|
+
{ name: "opencode", project: ".agents/skills", global: ".config/opencode/skills", detect: ".opencode" },
|
|
18
|
+
{ name: "cursor", project: ".agents/skills", global: ".cursor/skills", detect: ".cursor" },
|
|
19
|
+
{ name: "codex", project: ".agents/skills", global: ".codex/skills", detect: ".codex" },
|
|
20
|
+
{ name: "claude-code", project: ".claude/skills", global: ".claude/skills", detect: ".claude" },
|
|
21
|
+
{ name: "windsurf", project: ".windsurf/skills", global: ".codeium/windsurf/skills", detect: ".windsurf" },
|
|
22
|
+
{ name: "amp", project: ".agents/skills", global: ".config/agents/skills", detect: ".amp" },
|
|
23
|
+
{ name: "gemini-cli", project: ".agents/skills", global: ".gemini/skills", detect: ".gemini" },
|
|
24
|
+
{ name: "github-copilot", project: ".agents/skills", global: ".copilot/skills", detect: ".github" },
|
|
25
|
+
{ name: "goose", project: ".goose/skills", global: ".config/goose/skills", detect: ".goose" },
|
|
26
|
+
{ name: "devin", project: ".devin/skills", global: ".config/devin/skills", detect: ".devin" },
|
|
27
|
+
{ name: "kilo-code", project: ".agents/skills", global: ".kilocode/skills", detect: ".kilocode" },
|
|
28
|
+
{ name: "cline", project: ".agents/skills", global: ".cline/skills", detect: ".cline" },
|
|
29
|
+
{ name: "roo", project: ".roo/skills", global: ".roo/skills", detect: ".roo" },
|
|
30
|
+
{ name: "crush", project: ".crush/skills", global: ".config/crush/skills", detect: ".crush" },
|
|
31
|
+
{ name: "aider-desk", project: ".aider-desk/skills", global: ".aider-desk/skills", detect: ".aider-desk" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The canonical install location. Everything else either is this directory or
|
|
36
|
+
* points at it, which keeps a skill's files in exactly one place per project.
|
|
37
|
+
*/
|
|
38
|
+
export const CANONICAL_AGENT = "opencode";
|
|
39
|
+
|
|
40
|
+
export function getAgent(name) {
|
|
41
|
+
const agent = AGENTS.find((candidate) => candidate.name === name);
|
|
42
|
+
if (!agent) {
|
|
43
|
+
throw new CliError(`Unknown agent "${name}"`, {
|
|
44
|
+
hint: `Known agents: ${AGENTS.map((candidate) => candidate.name).join(", ")}. Or use --agent-dir <path>.`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return agent;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function skillsDirFor(agentName, { projectRoot, global }) {
|
|
51
|
+
const agent = getAgent(agentName);
|
|
52
|
+
const relative = global ? agent.global : agent.project;
|
|
53
|
+
return global
|
|
54
|
+
? path.join(os.homedir(), relative)
|
|
55
|
+
: path.join(projectRoot, relative);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Agents that look installed in this project (or in the user's home directory).
|
|
60
|
+
* `ds-cs add` uses this to pre-select targets in interactive mode; with no
|
|
61
|
+
* detection at all it falls back to the canonical directory.
|
|
62
|
+
*/
|
|
63
|
+
export async function detectAgents({ projectRoot, global }) {
|
|
64
|
+
const detected = [];
|
|
65
|
+
for (const agent of AGENTS) {
|
|
66
|
+
const marker = agent.detect;
|
|
67
|
+
if (!marker) continue;
|
|
68
|
+
const projectMarker = path.join(projectRoot, marker);
|
|
69
|
+
const globalMarker = path.join(os.homedir(), marker);
|
|
70
|
+
if ((await exists(projectMarker)) || (await exists(globalMarker))) {
|
|
71
|
+
detected.push(agent.name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (detected.length === 0 && !global) {
|
|
76
|
+
return [CANONICAL_AGENT];
|
|
77
|
+
}
|
|
78
|
+
return [...new Set(detected)];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function dedupeDirectories(directories) {
|
|
82
|
+
const seen = new Map();
|
|
83
|
+
for (const directory of directories) {
|
|
84
|
+
if (!seen.has(directory)) seen.set(directory, directory);
|
|
85
|
+
}
|
|
86
|
+
return [...seen.values()];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Install one skill directory into an agent's skills folder.
|
|
91
|
+
*
|
|
92
|
+
* Symlink by default: every agent points at the canonical copy, so an update
|
|
93
|
+
* lands everywhere at once and there is one source of truth. `--copy` is the
|
|
94
|
+
* escape hatch for filesystems or setups where symlinks do not work.
|
|
95
|
+
*/
|
|
96
|
+
export async function linkSkill(skillDir, skillsDirectory, { copy }) {
|
|
97
|
+
const target = path.join(skillsDirectory, path.basename(skillDir));
|
|
98
|
+
|
|
99
|
+
if (await exists(target)) {
|
|
100
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
await fs.mkdir(skillsDirectory, { recursive: true });
|
|
103
|
+
|
|
104
|
+
if (copy) {
|
|
105
|
+
await fs.cp(skillDir, target, { recursive: true });
|
|
106
|
+
} else {
|
|
107
|
+
try {
|
|
108
|
+
await fs.symlink(path.resolve(skillDir), target, "dir");
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// Windows without developer mode, some container mounts, and certain
|
|
111
|
+
// network filesystems reject symlinks. Copying still produces a correct
|
|
112
|
+
// install, so degrade instead of failing the whole command.
|
|
113
|
+
await fs.cp(skillDir, target, { recursive: true });
|
|
114
|
+
return { target, linked: false, fallbackReason: error.code ?? "symlink unsupported" };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { target, linked: !copy };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function unlinkSkill(name, skillsDirectory) {
|
|
122
|
+
const target = path.join(skillsDirectory, name);
|
|
123
|
+
if (!(await exists(target))) return false;
|
|
124
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function skillsDirectoryExists(skillsDirectory) {
|
|
129
|
+
return isDirectory(skillsDirectory);
|
|
130
|
+
}
|
package/src/args.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny argument parser. Enough for a CLI of this size and nothing more: no
|
|
3
|
+
* dependency, and unknown flags fail loudly instead of being silently dropped
|
|
4
|
+
* (a silently-ignored `--global` is worse than an error).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const ALIASES = {
|
|
8
|
+
g: "global",
|
|
9
|
+
a: "agent",
|
|
10
|
+
s: "skill",
|
|
11
|
+
y: "yes",
|
|
12
|
+
l: "list",
|
|
13
|
+
h: "help",
|
|
14
|
+
v: "version",
|
|
15
|
+
p: "project",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Flags that take a value. Everything else is treated as boolean. */
|
|
19
|
+
const VALUE_FLAGS = new Set([
|
|
20
|
+
"agent",
|
|
21
|
+
"skill",
|
|
22
|
+
"url",
|
|
23
|
+
"api-key",
|
|
24
|
+
"agent-dir",
|
|
25
|
+
"cwd",
|
|
26
|
+
"scope",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export function parseArgs(argv) {
|
|
30
|
+
const flags = {};
|
|
31
|
+
const positional = [];
|
|
32
|
+
|
|
33
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
34
|
+
const token = argv[index];
|
|
35
|
+
|
|
36
|
+
if (token === "--") {
|
|
37
|
+
positional.push(...argv.slice(index + 1));
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (token.startsWith("--")) {
|
|
42
|
+
let [name, inlineValue] = splitFlag(token.slice(2));
|
|
43
|
+
name = ALIASES[name] ?? name;
|
|
44
|
+
|
|
45
|
+
if (inlineValue !== undefined) {
|
|
46
|
+
setFlag(flags, name, inlineValue);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const next = argv[index + 1];
|
|
51
|
+
if (VALUE_FLAGS.has(name)) {
|
|
52
|
+
if (next === undefined || next.startsWith("-")) {
|
|
53
|
+
throw new Error(`--${name} needs a value`);
|
|
54
|
+
}
|
|
55
|
+
setFlag(flags, name, next);
|
|
56
|
+
index += 1;
|
|
57
|
+
} else {
|
|
58
|
+
flags[name] = true;
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (token.startsWith("-") && token.length > 1) {
|
|
64
|
+
for (const letter of token.slice(1)) {
|
|
65
|
+
const name = ALIASES[letter] ?? letter;
|
|
66
|
+
if (VALUE_FLAGS.has(name)) {
|
|
67
|
+
const next = argv[index + 1];
|
|
68
|
+
if (next === undefined || next.startsWith("-")) {
|
|
69
|
+
throw new Error(`-${letter} needs a value`);
|
|
70
|
+
}
|
|
71
|
+
setFlag(flags, name, next);
|
|
72
|
+
index += 1;
|
|
73
|
+
} else {
|
|
74
|
+
flags[name] = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
positional.push(token);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { flags, positional };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function splitFlag(text) {
|
|
87
|
+
const equals = text.indexOf("=");
|
|
88
|
+
if (equals === -1) return [text, undefined];
|
|
89
|
+
return [text.slice(0, equals), text.slice(equals + 1)];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function setFlag(flags, name, value) {
|
|
93
|
+
const key = camelCase(name);
|
|
94
|
+
if (flags[key] === undefined) {
|
|
95
|
+
flags[key] = value;
|
|
96
|
+
} else if (Array.isArray(flags[key])) {
|
|
97
|
+
flags[key].push(value);
|
|
98
|
+
} else {
|
|
99
|
+
flags[key] = [flags[key], value];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function camelCase(value) {
|
|
104
|
+
return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Normalise a repeatable flag into an array; `'*'` means "every agent". */
|
|
108
|
+
export function toArray(value) {
|
|
109
|
+
if (value === undefined) return [];
|
|
110
|
+
return Array.isArray(value) ? value : [value];
|
|
111
|
+
}
|
package/src/auth.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { CliError } from "./utils/errors.mjs";
|
|
6
|
+
import { exists } from "./utils/fs.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Channel credentials live in one JSON file outside the project, with owner-only
|
|
10
|
+
* permissions.
|
|
11
|
+
*
|
|
12
|
+
* Keeping them out of the repo is the point: `ds-cs login` stores a key that can
|
|
13
|
+
* create and delete content, and a project-local config is one `git add -A` away
|
|
14
|
+
* from being published.
|
|
15
|
+
*/
|
|
16
|
+
export function configPath() {
|
|
17
|
+
const base =
|
|
18
|
+
process.env.DS_CS_CONFIG_DIR ??
|
|
19
|
+
(process.env.XDG_CONFIG_HOME
|
|
20
|
+
? path.join(process.env.XDG_CONFIG_HOME, "ds-cs")
|
|
21
|
+
: path.join(os.homedir(), ".config", "ds-cs"));
|
|
22
|
+
return path.join(base, "auth.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function readAuth() {
|
|
26
|
+
const file = configPath();
|
|
27
|
+
if (!(await exists(file))) return { baseUrl: null, apiKey: null, loggedInAt: null };
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(await fs.readFile(file, "utf8"));
|
|
30
|
+
return {
|
|
31
|
+
baseUrl: parsed.baseUrl ?? null,
|
|
32
|
+
apiKey: parsed.apiKey ?? null,
|
|
33
|
+
loggedInAt: parsed.loggedInAt ?? null,
|
|
34
|
+
};
|
|
35
|
+
} catch (error) {
|
|
36
|
+
throw new CliError(`Auth config at ${file} is not valid JSON`, {
|
|
37
|
+
hint: "Run `ds-cs logout` to clear it.",
|
|
38
|
+
cause: error,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function writeAuth({ baseUrl, apiKey }) {
|
|
44
|
+
const file = configPath();
|
|
45
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
46
|
+
await fs.writeFile(
|
|
47
|
+
file,
|
|
48
|
+
`${JSON.stringify({ baseUrl, apiKey, loggedInAt: new Date().toISOString() }, null, 2)}\n`,
|
|
49
|
+
{ encoding: "utf8", mode: 0o600 }
|
|
50
|
+
);
|
|
51
|
+
// Write with the mode, but also force it: an existing file keeps its old bits
|
|
52
|
+
// across a rewrite on some platforms.
|
|
53
|
+
await fs.chmod(file, 0o600).catch(() => {});
|
|
54
|
+
return file;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function clearAuth() {
|
|
58
|
+
const file = configPath();
|
|
59
|
+
if (!(await exists(file))) return false;
|
|
60
|
+
await fs.rm(file);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve the channel URL for a command: flag, then stored login, then env. */
|
|
65
|
+
export async function resolveTarget({ url: urlFlag } = {}) {
|
|
66
|
+
const stored = await readAuth();
|
|
67
|
+
const baseUrl = (urlFlag ?? stored.baseUrl ?? process.env.DS_CS_URL ?? "").trim();
|
|
68
|
+
const apiKey = (stored.apiKey ?? process.env.DS_CS_API_KEY ?? "").trim();
|
|
69
|
+
|
|
70
|
+
if (!baseUrl) {
|
|
71
|
+
throw new CliError("No channel URL configured", {
|
|
72
|
+
hint: "Run `ds-cs login --url https://your-channel.com --api-key <key>`, or pass --url.",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, stored };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function redact(apiKey) {
|
|
80
|
+
if (!apiKey) return "";
|
|
81
|
+
return apiKey.length <= 8 ? "***" : `${apiKey.slice(0, 4)}…${apiKey.slice(-4)}`;
|
|
82
|
+
}
|