dsh-gh-pages-artifacts 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VibOtaku
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,227 @@
1
+ # dsh-gh-pages-artifacts
2
+
3
+ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) plugin that lets agents publish **artifacts** (HTML pages and Markdown documents) as shareable links on **GitHub Pages**.
4
+
5
+ Ask the agent for a report, dashboard, chart, or write-up "as a page". It writes the file, publishes it, and replies with a link like `https://you.github.io/dsh-artifacts/q3-report-k3x9ab/`. Later it can update the same artifact in place, so the link never changes. It can also list, read back, and delete artifacts.
6
+
7
+ > **Published artifacts are public.** GitHub Pages sites are reachable by anyone with the link. On GitHub Free the repository must be public too, and git history keeps old versions even after deletion. The plugin asks before publishing or deleting (except in *Full access* sessions, see [Behaviour details](#behaviour-details)), adds `noindex`, and refuses hidden files and content that looks like a credential, but you decide what gets shared.
8
+
9
+ ## What the agent gets
10
+
11
+ | Tool | What it does | Approval |
12
+ |---|---|---|
13
+ | `artifact_publish` | Create an artifact from a workspace file (`path`) or inline `content`, or update one in place (`id`). Markdown is rendered to a clean page with light and dark themes; HTML is served as-is. Extra files (images, CSS, JS, data) go in `assets`. | yes |
14
+ | `artifact_list` | List artifacts, newest first, with ids, titles, URLs; filter with `query`. | no |
15
+ | `artifact_read` | Read an artifact's source (or another text file of it) in windows. | no |
16
+ | `artifact_delete` | Delete an artifact. Its id is never reused, so an old link can't show new content. | yes |
17
+ | `artifact_status` | Diagnose the setup (token, repository, Pages settings) and check whether a deployment or artifact is live. | no |
18
+ | `artifact_repository` | Show or change where new artifacts go: link an existing repository (`owner/name` or a remote URL), unlink it, or switch between the shared and per-artifact strategies. | no |
19
+
20
+ The plugin also adds:
21
+
22
+ - a short system-prompt section so the model knows when to use the tools, and
23
+ - a bundled `artifact-pages` skill with page-design guidance. Users can invoke it as `/artifact-pages`, and a same-named skill in `~/.dsh/skills` or the project overrides it.
24
+
25
+ ## Repository strategies
26
+
27
+ - **`shared`** (default): every artifact gets a folder in one repository, at `https://<owner>.github.io/<repo>/<id>/`. The repository is, in order: one you **linked** at runtime (*"publish my artifacts to github.com/me/team-pages"*, which the agent does with `artifact_repository` `link`), the `repository` option (`owner/name` or a remote URL such as `git@github.com:me/team-pages.git`), or `owner`/`repo`.
28
+ - **`per-artifact`**: each new artifact gets a **new repository** of its own, `<owner>/<repoPrefix><id>` (default `artifact-<id>`), served at `https://<owner>.github.io/artifact-<id>/`. The plugin creates the repository, publishes the page at its root, turns on GitHub Pages, tags it with the `dsh-artifact` topic, and sets the page URL as the repository's homepage. This needs a token that may create repositories (see the token section).
29
+
30
+ Set it on the plugin's settings page, with the `repoStrategy` option, or in chat (*"from now on, put each artifact in its own repo"*). A choice made in chat is saved into the same settings, so the settings page and your profile file always show what is in effect. (Without dsh's settings service, for example in a custom composition, the choice is kept in the registry instead and overrides the configuration.) Existing artifacts always stay where they were published; updates, reads, and deletes find them through the registry.
31
+
32
+ ## Tracking what was published
33
+
34
+ Click **Artifacts** in the dsh sidebar (Web and Desktop) to see every published page and document, newest first. Each entry shows its title, link, description, repository, and last update. Links open in a new browser tab (on Desktop, in your default browser). **Refresh** also checks GitHub for artifacts published from another machine. To delete one, hover its row, click the trash icon, and confirm. The plugin commits the removal of its files from the repository and tombstones its id, and the page returns 404 once GitHub Pages redeploys (usually within a minute). Because you confirmed in the panel yourself, the agent approval prompt does not apply here.
35
+
36
+ Every publish, update, and delete is recorded in a local registry at `$DSH_HOME/gh-pages-artifacts/registry.json` (option `registryDir`). Next to it, `index.html` lists every artifact with **clickable links** to its page and repository, its kind, revision, last update, and whether it is live or deleted. `artifact_list` returns the same list as Markdown links, across all repositories and strategies, and adds artifacts it finds in the shared repository that were published from another machine. Ask *"what have you published?"* or *"show me the artifact index"* (the agent can present the index page in the side panel).
37
+
38
+ ## How it works
39
+
40
+ - **The repository holds the content.** Each artifact lives in its own folder on the Pages branch (or at the root of its own repository with the per-artifact strategy) (`<id>/index.html`, plus `source.md` for Markdown and any assets). A manifest `.dsh-artifacts.json` at the branch root records ids, titles, descriptions, kinds, revisions, and timestamps. With `siteDir: ''` (the default) the manifest is served too, so anyone who knows the site URL can list every artifact. Publish from `/docs` (`siteDir: docs`) to keep it unlisted.
41
+ - **Every change is one atomic commit.** The commit is built with the Git Data API (blobs → tree → commit) and applied with a fast-forward-only ref update. If another writer (a second dsh window, another machine) moved the branch, the change is rebuilt on the new head and retried. Nothing is lost, and nothing is force-pushed.
42
+ - **`.nojekyll`** is committed so Pages serves files exactly as written. The plugin refuses to write into an existing Jekyll site (one with `_config.yml` and no `.nojekyll`), and it cannot publish to a protected branch.
43
+ - **URLs** are `<site>/<pathPrefix>/<id>/`. The site URL comes from the Pages API, which handles custom domains; set `baseUrl` to override it. Ids are a slug of the title plus a random suffix (`q3-report-k3x9ab`), or a `slug` you choose.
44
+ - **Pages caching:** a change is usually live within a minute. Browsers may keep the previous version for up to 10 minutes.
45
+
46
+ ## Requirements
47
+
48
+ - DeepSeek Harness **0.2.0-rc.2 or newer, within 0.2.x** (Web, Desktop, or CLI).
49
+ - A GitHub account and a repository with GitHub Pages enabled (the setup command below does this for you).
50
+ - A GitHub token with **Contents: read and write** (and preferably **Pages: read-only**) on that repository only. The per-artifact strategy needs a token that may create repositories instead (see below).
51
+
52
+ ## Setup
53
+
54
+ ### 1. Create the repository and enable Pages
55
+
56
+ Run this yourself in a terminal (not through the agent), from a checkout of this repository, with the [GitHub CLI](https://cli.github.com) logged in (`gh auth login`):
57
+
58
+ ```bash
59
+ node bin/setup.mjs setup --author "Your Name <you@example.com>"
60
+ ```
61
+
62
+ It creates a **public** repository `dsh-artifacts` under your account (asking first), creates the `gh-pages` branch, and enables GitHub Pages from it. `--author` sets the author of the commits it makes; without it GitHub uses your account's identity. Other options: `--owner <org>`, `--repo <name>`, `--branch <name>`, `--docs` (publish from `/docs`), `--private` (paid plans only; implies `--docs`; the pages are still public), and `--yes`. Once the package is published to npm, `npx dsh-gh-pages-artifacts setup` does the same.
63
+
64
+ To do it by hand instead:
65
+
66
+ 1. Create a repository, for example `dsh-artifacts`. Do **not** use your `<you>.github.io` site repository, because artifacts would share its root.
67
+ 2. In **Settings → Pages**, choose **Deploy from a branch**, then branch `gh-pages` and folder `/ (root)`. If the branch does not exist yet, the plugin creates it on first publish; pushing a `gh-pages` branch usually enables Pages automatically.
68
+
69
+ ### 2. Create a token
70
+
71
+ Create a [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new):
72
+
73
+ - **Repository access:** Only select repositories → your artifacts repository
74
+ - **Permissions:**
75
+ - **Contents:** Read and write (required)
76
+ - **Pages:** Read-only (recommended; lets `artifact_status` verify the Pages settings and deployments, and lets the plugin discover custom domains)
77
+
78
+ A classic token with the `public_repo` scope also works but grants far more access. Avoid reusing `gh auth token`: it carries all of your CLI's scopes.
79
+
80
+ For the **per-artifact** strategy the token must create repositories and turn on Pages: a fine-grained token with **Repository access: All repositories** and **Administration**, **Contents**, and **Pages** set to *Read and write*, or a classic token with `public_repo` (or `repo` for private repositories).
81
+
82
+ ### 3. Give the token to dsh
83
+
84
+ The plugin reads the credential named by `tokenEnv` (default **`GH_PAGES_TOKEN`**). It never takes the token from the conversation. **Never paste it into a chat.** Use any one of these sources (highest precedence first):
85
+
86
+ 1. **Environment** of the process that launches dsh: `export GH_PAGES_TOKEN=github_pat_...`. Desktop on macOS and Linux imports your login shell's exports at start, so restart the app after changing them.
87
+ 2. **`$DSH_HOME/.credentials.yaml`** (default `~/.dsh/.credentials.yaml`). It hot-reloads and must be `chmod 600`:
88
+
89
+ ```yaml
90
+ version: 1
91
+ refs:
92
+ GH_PAGES_TOKEN: github_pat_...
93
+ ```
94
+
95
+ If the file already exists, add the key under its existing `refs:`. Don't add a second `refs:`.
96
+ 3. A `.env` in the directory you launched dsh from. It is read once at launch, so restart after editing it. If the token comes from here, the plugin requires the `owner` option, so a repository's own `.env` can't silently redirect your publishes to someone else's account.
97
+ 4. `$DSH_HOME/.env`. Also read at launch, so restart after editing it.
98
+
99
+ A value from the environment wins and is read-only. Token-shaped variable names (containing `TOKEN`) are scrubbed from the agent's shell commands. The agent's processes run as your OS user, though, so treat this as discretion, not a security boundary.
100
+
101
+ ### 4. Install the plugin
102
+
103
+ **From the latest release** (recommended; prebuilt, so nothing is built on your machine):
104
+
105
+ ```bash
106
+ dsh plugin --profile web add https://github.com/opdsh/dsh-gh-pages-artifacts/releases/latest/download/dsh-gh-pages-artifacts.tgz
107
+ ```
108
+
109
+ On **Desktop / Web**, open **Plugins → Add plugin** and enter the same URL. Restart running CLI profiles after installing.
110
+
111
+ **From source on GitHub.** The package builds itself on install, so pnpm blocks the first attempt until you allow that build:
112
+
113
+ ```bash
114
+ dsh plugin --profile web add github:opdsh/dsh-gh-pages-artifacts
115
+ ```
116
+
117
+ dsh then prints a key like `dsh-gh-pages-artifacts@https://codeload.github.com/opdsh/dsh-gh-pages-artifacts/tar.gz/<commit>`. Add it with `: true` under `allowBuilds:` in the profile's `pnpm-workspace.yaml` (dsh prints the path), then run the command again. The key names one commit, so allow it again after updating.
118
+
119
+ **From a local checkout.** Build it first (`lib/` is not committed):
120
+
121
+ ```bash
122
+ git clone https://github.com/opdsh/dsh-gh-pages-artifacts.git
123
+ ```
124
+
125
+ ```bash
126
+ cd dsh-gh-pages-artifacts && pnpm install && pnpm run build
127
+ ```
128
+
129
+ ```bash
130
+ dsh plugin --profile web add "$PWD"
131
+ ```
132
+
133
+ ### 5. Configure it (optional)
134
+
135
+ Open **Plugins → GitHub Pages Artifacts**. The plugin's page has its settings:
136
+
137
+ - **GitHub token:** whether one is configured, and a field that saves it to dsh's credential store (not available when the token comes from the environment)
138
+ - **Where artifacts are published:** the strategy, the shared repository (`owner/name` or a remote URL), the owner, prefix, and visibility of new repositories, and the Pages branch
139
+ - **Commits:** the author name and email
140
+ - **Safety:** approval, `noindex`, and secret blocking
141
+
142
+ Changes apply to the next publish without a restart, and are saved to your profile's `cordis.patch.yml`.
143
+
144
+ You can also edit that file directly. The defaults work when the repository is `dsh-artifacts` under the token's user and Pages publishes `gh-pages` from the root; otherwise add a row like this:
145
+
146
+ - CLI: `~/.dsh/profiles/<profile>/cordis.patch.yml`
147
+ - Desktop: **Settings → Open configuration file**
148
+
149
+ ```yaml
150
+ - id: gh-pages-artifacts
151
+ config:
152
+ owner: your-login-or-org
153
+ repo: dsh-artifacts
154
+ ```
155
+
156
+ A patch replaces the row's whole `config`, and any key you leave out uses its default. Check the composed result with `dsh --profile web --dump-config`. Changes reload live in the Web and Desktop apps.
157
+
158
+ Then ask the agent: *"Make a one-page summary of this repo's architecture and publish it."* If anything is off, ask it to run `artifact_status`.
159
+
160
+ ## Configuration reference
161
+
162
+ | Option | Default | Meaning |
163
+ |---|---|---|
164
+ | `owner` | token's user | User or organization that owns the repository. |
165
+ | `repo` | `dsh-artifacts` | Repository that hosts artifacts. |
166
+ | `repository` | none | The shared repository as `owner/name` or a remote URL (`https://github.com/owner/name.git`, `git@github.com:owner/name.git`); overrides `owner`/`repo` for it. A repository linked at runtime overrides this. |
167
+ | `repoStrategy` | `shared` | `shared` (one repository) or `per-artifact` (a new repository for each new artifact). A runtime choice overrides this. |
168
+ | `repoPrefix` | `artifact-` | Name prefix of repositories the per-artifact strategy creates. |
169
+ | `repoVisibility` | `public` | Visibility of repositories the per-artifact strategy creates. Pages on private repositories needs a paid plan; the pages are public either way. |
170
+ | `registryDir` | `$DSH_HOME/gh-pages-artifacts` | Folder for the local registry and its `index.html`. |
171
+ | `branch` | `gh-pages` | Branch Pages publishes from. Created on first publish when missing. |
172
+ | `siteDir` | `''` | Pages source folder: `''` (root) or `docs`. |
173
+ | `pathPrefix` | `''` | Folder under the site root for artifacts, e.g. `a` → `<site>/a/<id>/`. |
174
+ | `baseUrl` | from the Pages API | Public site URL. Set it for custom domains when the token lacks Pages read, and on GitHub Enterprise Server (required there unless the token can read Pages). |
175
+ | `tokenEnv` | `GH_PAGES_TOKEN` | Credential reference (environment variable name) holding the token. |
176
+ | `apiBaseUrl` | `https://api.github.com` | REST API base; change it only for GitHub Enterprise Server. |
177
+ | `approval` | `unless-full-access` | `unless-full-access`: ask before publish and delete, except in *Full access* sessions (danger-full-access sandbox with approval prompts turned off). `always`: always ask. `off`: never ask. |
178
+ | `hideFromPresets` | `['minimal']` | Agent modes that don't get the artifact tools. |
179
+ | `subagentAccess` | `read-only` | Delegated subagents: `read-only` (list, read, status), `full`, or `none`. |
180
+ | `noindex` | `true` | Add `<meta name="robots" content="noindex, nofollow">` to pages, even if the page asks to be indexed. |
181
+ | `csp` | `object-src 'none'; base-uri 'none'` | Content-Security-Policy meta tag added to pages; `''` disables it. |
182
+ | `blockSecrets` | `true` | Refuse hidden and credential files (`.env`, keys, `.ssh/`, ...) and any title, description, page, or asset that looks like a credential (GitHub/AWS/Slack/npm/API keys, JWTs, private keys, passwords in URLs or assignments, the configured token in any common encoding). |
183
+ | `maxPublishBytes` | `10485760` | Maximum total bytes of one publish (page plus assets). |
184
+ | `commitAuthor` | token's user | `{ name, email }` used as commit author and committer. |
185
+ | `promptGuidance` | `true` | Add the short system-prompt section. |
186
+ | `bundledSkill` | `true` | Register the `artifact-pages` skill. |
187
+
188
+ ## Behaviour details
189
+
190
+ - **Approval.** Publish, update, and delete ask through the dsh approval panel. The prompt leads with the exact URL and lists the source file (or inline content size), every asset as `source → published name`, removed assets, and the total size. Titles are quoted and may not contain control or bidi characters, so they can't disguise the request. With the default `unless-full-access`, only *Full access* sessions publish without a prompt; *Auto* sessions are still asked. Headless runs and SDK sessions without an approval channel fail closed unless you set `approval: off`. Subagents cannot ask for approval, so by default they only get the read-only tools; this is enforced when the tools run, not just by hiding them.
191
+ - **Workspace confinement.** `path` and `assets` must be regular files inside the session's working directory. Symlinks, directories, paths that resolve outside it, hidden files, and credential-looking files are refused. The page itself must be `.html`, `.htm`, `.md`, `.markdown`, or `.txt`. This blocks path tricks, but it cannot stop an agent from copying data into the workspace or passing it inline. The approval prompt, and you, remain the real control.
192
+ - **Markdown** is rendered with GitHub-flavored Markdown (tables, task lists, footnotes, strikethrough, autolinks). Raw HTML inside Markdown is escaped and `javascript:` links are dropped. Publish HTML for anything interactive.
193
+ - **HTML** is published as written. A fragment is wrapped into a complete document. The plugin inserts only the robots and CSP meta tags right after `<head>`, marked with `data-dsh-artifacts` so `artifact_read` returns the page without them. Inline `content` counts as HTML only when it is a whole document (`<!doctype html>` or `<html>`); anything else is rendered as Markdown.
194
+ - **Updates** replace the page, keep assets you don't mention, and remove those listed in `removeAssets`. Pass `baseRev` to refuse an update when someone else changed the artifact since you read it.
195
+ - **Delete** removes the files from the branch head and tombstones the id. The content stays in git history, and anyone who saved a copy keeps it.
196
+ - **Status.** `artifact_status` compares the latest Pages build with the branch head, so "deployed" means your newest change is live. With `wait: true` it waits up to five minutes. Real deployments usually take one to three minutes.
197
+ - **Origin isolation.** All project sites of one owner share the `https://<owner>.github.io` origin (cookies, localStorage). If you run other apps on that origin, consider a separate account or organization, or a custom domain, for artifacts.
198
+
199
+ ## Development
200
+
201
+ Releases are cut by pushing a tag that matches `package.json` (for example `v0.1.0`); the release workflow tests, builds, and attaches `dsh-gh-pages-artifacts.tgz` to the GitHub Release.
202
+
203
+ ```bash
204
+ pnpm install
205
+ pnpm run typecheck
206
+ pnpm test # unit, integration (real dsh tool/approval/fs/skill services), and setup-script tests against an in-memory GitHub
207
+ pnpm run build # emits lib/
208
+ ```
209
+
210
+ To try a local checkout without installing it, run dsh with an overlay patch:
211
+
212
+ ```yaml
213
+ # dev.patch.yml
214
+ - insert:
215
+ - id: gh-pages-artifacts
216
+ name: /absolute/path/to/gh-pages-plugin/lib/index.js
217
+ config:
218
+ owner: your-login
219
+ ```
220
+
221
+ ```bash
222
+ dsh web --patch ./dev.patch.yml
223
+ ```
224
+
225
+ ## License
226
+
227
+ MIT
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: artifact-pages
3
+ description: Design, write, and publish polished web pages and documents as shareable GitHub Pages links with the artifact_publish tool. Use when the user asks for something to open in a browser or share by link, such as a report, dashboard, chart, interactive demo, slide-style page, or long-form document, or asks to publish, update, list, or delete a published artifact.
4
+ ---
5
+
6
+ # Publishing artifacts on GitHub Pages
7
+
8
+ An artifact is one HTML page (with optional asset files) or one Markdown document, published at a stable public URL. The `artifact_*` tools handle the GitHub side. Your job is to make the page worth opening.
9
+
10
+ ## Pick the kind
11
+
12
+ - **Markdown document** (`.md`): prose-first content such as reports, write-ups, notes, guides, and READMEs. The plugin renders GitHub-flavored Markdown (tables, task lists, footnotes, code blocks) into a clean, readable page with light and dark themes. Raw HTML inside Markdown is escaped, so use plain Markdown.
13
+ - **HTML page** (`.html`): anything visual or interactive, such as dashboards, charts, calculators, explorable explanations, slide-style pages, and landing pages. The file is served as-is.
14
+
15
+ ## Workflow
16
+
17
+ 1. Write the page to a workspace file, e.g. `report.md` or `dashboard.html`. Do not paste long sources into `content`.
18
+ 2. Check it before publishing. Render-check HTML mentally or with available tools, make sure every asset path resolves, and read the text once for accuracy.
19
+ 3. Publish: `artifact_publish({ description, title, path })`. Add `assets` for images or data files the page references by relative URL.
20
+ 4. Reply with the link as Markdown: `[Title](url)`. Mention that it can take about a minute to go live.
21
+ 5. To change it later, edit the file and call `artifact_publish({ description, id, path })` with the same `id`, so the URL stays the same. If you did not write the current version yourself, `artifact_read` it first and pass `baseRev`.
22
+
23
+ Use `artifact_status` with `id` and `wait: true` when the user wants confirmation that the page is live, or when something looks wrong (missing token, Pages disabled, failed build).
24
+
25
+ ## Privacy and safety
26
+
27
+ - Everything published is public, and the repository history keeps old versions even after deletion. Never include credentials, tokens, personal data, internal URLs, or private conversation content unless the user explicitly asked to publish exactly that.
28
+ - Never ask the user to paste a GitHub token into the chat. The token is configured outside the conversation (see the plugin README).
29
+ - Do not publish pages that imitate a real organization's or person's site, or forms that collect passwords or payment details.
30
+
31
+ ## HTML pages that look good
32
+
33
+ Prefer one self-contained file: inline `<style>` and `<script>`, plus a few external libraries from a CDN when they genuinely help (for example a charting library from cdn.jsdelivr.net or cdnjs.cloudflare.com, pinned to an exact version).
34
+
35
+ Structure:
36
+
37
+ ```html
38
+ <!doctype html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="utf-8">
42
+ <meta name="viewport" content="width=device-width, initial-scale=1">
43
+ <title>Clear, specific title</title>
44
+ <style>
45
+ :root { color-scheme: light dark; --bg: #fff; --fg: #1f2328; --muted: #59636e; --accent: #0969da; --border: #d1d9e0; }
46
+ @media (prefers-color-scheme: dark) { :root { --bg: #0d1117; --fg: #e6edf3; --muted: #9198a1; --accent: #4493f8; --border: #3d444d; } }
47
+ body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.6 system-ui, sans-serif; }
48
+ main { max-width: 64rem; margin: 0 auto; padding: 2rem 1rem; }
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <main>...</main>
53
+ </body>
54
+ </html>
55
+ ```
56
+
57
+ Design rules:
58
+
59
+ - Define colors once as CSS variables and support dark mode with `prefers-color-scheme`. Give `body` an explicit background.
60
+ - Make it responsive. It must work at 360 px wide with no horizontal page scroll: use fluid widths, `max-width`, CSS grid or flex with wrapping, and `overflow-x: auto` on wide tables and code.
61
+ - Use a clear hierarchy: one `h1`, short intro, sections with headings, generous whitespace, and a readable line length (60-80 characters).
62
+ - Charts need titles, labeled axes with units, legends when there is more than one series, and accessible colors. Embed the data in the page (a JSON block or a JS constant) so it works offline from the CDN cache.
63
+ - Use system fonts, or one web font if the design needs it.
64
+ - Accessibility: semantic elements (`main`, `nav`, `section`, `button`), alt text on images, visible focus states, sufficient contrast, and keyboard-operable controls.
65
+ - Interactive state should live in the page. Do not rely on server endpoints: GitHub Pages serves static files only.
66
+ - Keep it fast: no huge images (compress or resize first), no autoplaying media, and lazy-load below-the-fold images.
67
+
68
+ ## Markdown documents that read well
69
+
70
+ - Start with one `# Title` that matches the artifact title, then a one-paragraph summary.
71
+ - Use `##` sections, short paragraphs, lists for parallel items, and tables for comparisons.
72
+ - Use fenced code blocks with a language tag.
73
+ - Put images in `assets` and reference them relatively: `![Revenue by month](chart.png)`.
74
+ - End with sources or next steps when relevant.
75
+
76
+ ## Assets
77
+
78
+ `assets: [{ path: "out/chart.png" }, { path: "data.json", name: "data/data.json" }]` publishes files next to the page. Reference them with relative URLs (`chart.png`, `data/data.json`). On update, listed assets are added or replaced and the others are kept. Use `removeAssets` to delete one. The total size of one publish is limited (10 MiB by default).
79
+
80
+ ## Managing artifacts
81
+
82
+ - `artifact_list`: find ids and URLs, with an optional text `query`.
83
+ - `artifact_read`: fetch the current source in windows (`offset`/`limit`) before editing someone else's version.
84
+ - `artifact_delete`: remove a page when the user asks. The id is never reused.
package/bin/setup.mjs ADDED
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env node
2
+ // One-time setup for dsh-gh-pages-artifacts, run by a person (not by the agent).
3
+ // It uses your own GitHub CLI login to create the artifacts repository, the Pages branch,
4
+ // and the GitHub Pages site, then prints the plugin configuration and token instructions.
5
+ import { execFileSync } from 'node:child_process'
6
+ import { createInterface } from 'node:readline/promises'
7
+ import { stdin, stdout, argv, env, exit } from 'node:process'
8
+
9
+ const USAGE = `Usage: node bin/setup.mjs setup [options] (or: npx dsh-gh-pages-artifacts setup)
10
+
11
+ Creates (when missing) a public GitHub repository for artifacts, a Pages branch, and
12
+ the GitHub Pages site, using your GitHub CLI ("gh") login. Nothing is changed without
13
+ confirmation unless --yes is given.
14
+
15
+ Options:
16
+ --owner <login> User or organization that owns the repository (default: your gh user)
17
+ --repo <name> Repository name (default: dsh-artifacts)
18
+ --branch <name> Pages branch (default: gh-pages)
19
+ --docs Publish from the /docs folder instead of the branch root
20
+ --private Create a private repository (GitHub Pro/Team/Enterprise only;
21
+ the published pages are still public). Implies --docs, so the
22
+ artifact index at the branch root is not served
23
+ --author "Name <email>"
24
+ Author and committer of the commits setup creates
25
+ (default: GitHub's identity for your account)
26
+ --yes Do not ask for confirmation
27
+ -h, --help Show this help`
28
+
29
+ function parseArgs(args) {
30
+ const options = { owner: undefined, repo: 'dsh-artifacts', branch: 'gh-pages', docs: false, private: false, yes: false, author: undefined }
31
+ const rest = [...args]
32
+ if (rest[0] === 'setup') rest.shift()
33
+ while (rest.length > 0) {
34
+ const arg = rest.shift()
35
+ const value = () => {
36
+ const next = rest.shift()
37
+ if (next === undefined || next.startsWith('--')) fail(`${arg} needs a value`)
38
+ return next
39
+ }
40
+ switch (arg) {
41
+ case '--owner': options.owner = value(); break
42
+ case '--repo': options.repo = value(); break
43
+ case '--branch': options.branch = value(); break
44
+ case '--docs': options.docs = true; break
45
+ case '--private': options.private = true; break
46
+ case '--author': {
47
+ const match = /^\s*([^<>]*?)\s*<([^\s<>@]+@[^\s<>@]+)>\s*$/.exec(value())
48
+ if (match === null || match[1] === '') fail('--author must look like "Name <email@example.com>"')
49
+ options.author = { name: match[1], email: match[2] }
50
+ break
51
+ }
52
+ case '--yes': case '-y': options.yes = true; break
53
+ case '-h': case '--help': console.log(USAGE); exit(0); break
54
+ default: fail(`unknown option ${arg}\n\n${USAGE}`)
55
+ }
56
+ }
57
+ if (options.private) options.docs = true
58
+ if (!/^[A-Za-z0-9._-]{1,100}$/.test(options.repo)) fail(`invalid repository name ${options.repo}`)
59
+ if (options.owner !== undefined && !/^[A-Za-z0-9-]{1,39}$/.test(options.owner)) fail(`invalid owner ${options.owner}`)
60
+ if (!/^[A-Za-z0-9._/-]{1,200}$/.test(options.branch) || options.branch.includes('..')) fail(`invalid branch ${options.branch}`)
61
+ return options
62
+ }
63
+
64
+ function fail(message) {
65
+ console.error(`error: ${message}`)
66
+ exit(1)
67
+ }
68
+
69
+ /** Environment for gh: plain, uncoloured, unpaged output whatever the user's shell forces. */
70
+ const GH_ENV = (() => {
71
+ const clean = { ...env, CLICOLOR_FORCE: '0', NO_COLOR: '1', GH_PAGER: 'cat', GH_NO_UPDATE_NOTIFIER: '1', GH_PROMPT_DISABLED: '1' }
72
+ delete clean.GH_FORCE_TTY
73
+ return clean
74
+ })()
75
+
76
+ /** Run `gh api`; returns { ok, status, data }. Never prints the token. */
77
+ function ghApi(method, path, body) {
78
+ const args = ['api', '-X', method, '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', '-i', path]
79
+ if (body !== undefined) args.push('--input', '-')
80
+ let output
81
+ try {
82
+ output = execFileSync('gh', args, { input: body === undefined ? undefined : JSON.stringify(body), encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], env: GH_ENV })
83
+ } catch (error) {
84
+ if (error.code === 'ENOENT') fail('the GitHub CLI (gh) is not installed; get it from https://cli.github.com and run "gh auth login"')
85
+ output = `${error.stdout ?? ''}`
86
+ if (output === '') fail(`gh api ${method} ${path} failed: ${(error.stderr ?? error.message).trim()}`)
87
+ }
88
+ const split = output.search(/\r?\n\r?\n/)
89
+ const head = split < 0 ? output : output.slice(0, split)
90
+ const text = split < 0 ? '' : output.slice(split).trim()
91
+ const status = Number(/^HTTP\/[\d.]+ (\d{3})/m.exec(head)?.[1] ?? 0)
92
+ let data
93
+ try { data = text === '' ? undefined : JSON.parse(text) } catch { data = text }
94
+ return { ok: status >= 200 && status < 300, status, data }
95
+ }
96
+
97
+ /** Give a repository its first commit with a known author (auto_init would use the account's email). */
98
+ async function initializeRepository(owner, repo, options) {
99
+ const readme = '# Artifacts\n\nPages published by DeepSeek Harness agents with dsh-gh-pages-artifacts. They are served from the `' + options.branch + '` branch.\n'
100
+ let initialized
101
+ for (let attempt = 0; attempt < 5; attempt++) {
102
+ initialized = ghApi('PUT', `/repos/${owner}/${repo}/contents/README.md`, {
103
+ message: 'Initialize artifacts repository',
104
+ content: Buffer.from(readme).toString('base64'),
105
+ ...authorFields(options.author),
106
+ })
107
+ if (initialized.ok || initialized.status !== 404 && initialized.status !== 409) break
108
+ await new Promise(resolve => setTimeout(resolve, 2000))
109
+ }
110
+ if (!initialized.ok) fail(`could not initialize ${owner}/${repo} (HTTP ${initialized.status}): ${initialized.data?.message ?? ''}`)
111
+ }
112
+
113
+ function authorFields(author) {
114
+ return author === undefined ? {} : { author: { ...author }, committer: { ...author } }
115
+ }
116
+
117
+ async function confirm(question, yes) {
118
+ if (yes) return true
119
+ if (!stdin.isTTY) fail('this command changes your GitHub account and must be run by a person in an interactive terminal')
120
+ const rl = createInterface({ input: stdin, output: stdout })
121
+ try {
122
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase()
123
+ return answer === 'y' || answer === 'yes'
124
+ } finally {
125
+ rl.close()
126
+ }
127
+ }
128
+
129
+ async function main() {
130
+ const options = parseArgs(argv.slice(2))
131
+ const me = ghApi('GET', '/user')
132
+ if (!me.ok || typeof me.data?.login !== 'string') {
133
+ fail(`could not read your GitHub user (HTTP ${me.status}); run "gh auth login" for github.com and try again`)
134
+ }
135
+ const login = me.data.login
136
+ const owner = options.owner ?? login
137
+ const ownsRepo = owner.toLowerCase() === login.toLowerCase()
138
+ const repo = options.repo
139
+ const full = `${owner}/${repo}`
140
+ const sitePath = options.docs ? '/docs' : '/'
141
+ const nojekyll = options.docs ? 'docs/.nojekyll' : '.nojekyll'
142
+ if (repo.toLowerCase() === `${owner.toLowerCase()}.github.io`) {
143
+ fail(`use a dedicated repository, not your ${repo} site: artifacts would share its root and its service-worker scope`)
144
+ }
145
+
146
+ // 1. Repository.
147
+ let repository = ghApi('GET', `/repos/${owner}/${repo}`)
148
+ if (repository.status === 404) {
149
+ const visibility = options.private ? 'PRIVATE' : 'PUBLIC'
150
+ if (!await confirm(`Create the ${visibility} repository ${full}?`, options.yes)) fail('cancelled')
151
+ const body = {
152
+ name: repo,
153
+ description: 'Artifacts published by DeepSeek Harness agents (dsh-gh-pages-artifacts)',
154
+ private: options.private,
155
+ auto_init: false,
156
+ has_issues: false,
157
+ has_projects: false,
158
+ has_wiki: false,
159
+ }
160
+ if (!ownsRepo) {
161
+ const account = ghApi('GET', `/users/${owner}`)
162
+ if (account.data?.type !== 'Organization') fail(`you can only create repositories under your own account (${login}) or an organization you belong to`)
163
+ }
164
+ const created = ownsRepo ? ghApi('POST', '/user/repos', body) : ghApi('POST', `/orgs/${owner}/repos`, body)
165
+ if (!created.ok) fail(`could not create ${full} (HTTP ${created.status}): ${created.data?.message ?? ''}`)
166
+ console.log(`created ${created.data.html_url}`)
167
+ repository = created
168
+ await initializeRepository(owner, repo, options)
169
+ } else if (!repository.ok) {
170
+ fail(`could not read ${full} (HTTP ${repository.status}): ${repository.data?.message ?? ''}`)
171
+ } else {
172
+ console.log(`repository ${full} exists`)
173
+ }
174
+
175
+ // 2. Pages branch, created as an orphan holding only .nojekyll.
176
+ let ref = ghApi('GET', `/repos/${owner}/${repo}/git/ref/heads/${options.branch}`)
177
+ if (ref.status === 409 && /empty/i.test(ref.data?.message ?? '')) {
178
+ // An existing repository without commits: the Git Data API needs a first commit.
179
+ if (!await confirm(`${full} is empty. Add a README commit so branches can be created?`, options.yes)) fail('cancelled')
180
+ await initializeRepository(owner, repo, options)
181
+ ref = ghApi('GET', `/repos/${owner}/${repo}/git/ref/heads/${options.branch}`)
182
+ }
183
+ for (let attempt = 0; ref.status === 409 && attempt < 5; attempt++) {
184
+ await new Promise(resolve => setTimeout(resolve, 2000))
185
+ ref = ghApi('GET', `/repos/${owner}/${repo}/git/ref/heads/${options.branch}`)
186
+ }
187
+ if (ref.status === 404) {
188
+ if (!await confirm(`Create the branch ${options.branch} in ${full}?`, options.yes)) fail('cancelled')
189
+ const tree = ghApi('POST', `/repos/${owner}/${repo}/git/trees`, {
190
+ tree: [{ path: nojekyll, mode: '100644', type: 'blob', content: '# Disables Jekyll so GitHub Pages serves files exactly as committed.\n' }],
191
+ })
192
+ if (!tree.ok) fail(`could not create the branch tree (HTTP ${tree.status}): ${tree.data?.message ?? ''}`)
193
+ const commit = ghApi('POST', `/repos/${owner}/${repo}/git/commits`, { message: 'Initialize GitHub Pages branch for dsh artifacts', tree: tree.data.sha, parents: [], ...authorFields(options.author) })
194
+ if (!commit.ok) fail(`could not create the branch commit (HTTP ${commit.status}): ${commit.data?.message ?? ''}`)
195
+ const created = ghApi('POST', `/repos/${owner}/${repo}/git/refs`, { ref: `refs/heads/${options.branch}`, sha: commit.data.sha })
196
+ if (!created.ok) fail(`could not create branch ${options.branch} (HTTP ${created.status}): ${created.data?.message ?? ''}`)
197
+ console.log(`created branch ${options.branch}`)
198
+ } else if (!ref.ok) {
199
+ fail(`could not read branch ${options.branch} (HTTP ${ref.status}): ${ref.data?.message ?? ''}`)
200
+ } else {
201
+ console.log(`branch ${options.branch} exists`)
202
+ }
203
+
204
+ // 3. GitHub Pages site, deployed from the branch.
205
+ let pages = ghApi('GET', `/repos/${owner}/${repo}/pages`)
206
+ if (pages.status === 404) {
207
+ if (!await confirm(`Enable GitHub Pages for ${full} from ${options.branch} ${sitePath}?`, options.yes)) fail('cancelled')
208
+ const created = ghApi('POST', `/repos/${owner}/${repo}/pages`, { build_type: 'legacy', source: { branch: options.branch, path: sitePath } })
209
+ // Pushing a gh-pages branch can enable Pages on its own, so "already enabled" (409) is fine.
210
+ if (!created.ok && created.status !== 409) fail(`could not enable GitHub Pages (HTTP ${created.status}): ${created.data?.message ?? ''}`)
211
+ pages = ghApi('GET', `/repos/${owner}/${repo}/pages`)
212
+ if (!pages.ok) fail(`could not read the GitHub Pages settings (HTTP ${pages.status}): ${pages.data?.message ?? ''}`)
213
+ console.log(created.ok ? 'enabled GitHub Pages' : 'GitHub Pages was enabled automatically')
214
+ }
215
+ if (pages.ok) {
216
+ const source = pages.data.source ?? {}
217
+ if (pages.data.build_type === 'workflow' || source.branch !== options.branch || (source.path ?? '/') !== sitePath) {
218
+ const current = pages.data.build_type === 'workflow' ? 'a GitHub Actions workflow' : `${source.branch} ${source.path}`
219
+ if (await confirm(`GitHub Pages currently publishes from ${current}. Switch it to ${options.branch} ${sitePath}?`, options.yes)) {
220
+ const updated = ghApi('PUT', `/repos/${owner}/${repo}/pages`, { build_type: 'legacy', source: { branch: options.branch, path: sitePath } })
221
+ if (!updated.ok) fail(`could not update GitHub Pages (HTTP ${updated.status}): ${updated.data?.message ?? ''}`)
222
+ pages = ghApi('GET', `/repos/${owner}/${repo}/pages`)
223
+ console.log('updated the GitHub Pages source')
224
+ } else {
225
+ console.log('left the GitHub Pages source unchanged; artifacts will not be served until it matches')
226
+ }
227
+ } else {
228
+ console.log('GitHub Pages is enabled')
229
+ }
230
+ } else {
231
+ fail(`could not read the GitHub Pages settings (HTTP ${pages.status}): ${pages.data?.message ?? ''}`)
232
+ }
233
+
234
+ const siteUrl = String(pages.data?.html_url ?? `https://${owner.toLowerCase()}.github.io/${repo}/`).replace(/^http:\/\/([^/]+\.github\.io)/, 'https://$1')
235
+ const configLines = [
236
+ '- id: gh-pages-artifacts',
237
+ ' config:',
238
+ ` owner: ${owner}`,
239
+ ` repo: ${repo}`,
240
+ ...options.branch === 'gh-pages' ? [] : [` branch: ${options.branch}`],
241
+ ...options.docs ? [' siteDir: docs'] : [],
242
+ ...options.author === undefined ? [] : [' commitAuthor:', ` name: ${JSON.stringify(options.author.name)}`, ` email: ${options.author.email}`],
243
+ ]
244
+ console.log(`
245
+ Done. Artifacts will be served under ${siteUrl}
246
+
247
+ Next steps
248
+ 1. Create a fine-grained personal access token:
249
+ https://github.com/settings/personal-access-tokens/new
250
+ Resource owner: ${owner}
251
+ Repository access: Only select repositories -> ${full}
252
+ Repository permissions: Contents: Read and write; Pages: Read-only (recommended, for status checks)
253
+
254
+ 2. Give it to DeepSeek Harness as the GH_PAGES_TOKEN credential, for example in
255
+ $DSH_HOME/.credentials.yaml (default ~/.dsh/.credentials.yaml, chmod 600):
256
+
257
+ version: 1
258
+ refs:
259
+ GH_PAGES_TOKEN: <paste the token here, never in a chat>
260
+
261
+ or export GH_PAGES_TOKEN in the environment that launches dsh.
262
+
263
+ 3. Add this row to your profile's cordis.patch.yml
264
+ (~/.dsh/profiles/<profile>/cordis.patch.yml; Desktop: Settings -> Open configuration file):
265
+
266
+ ${configLines.map(line => ` ${line}`).join('\n')}
267
+ `)
268
+ }
269
+
270
+ main().catch(error => fail(error instanceof Error ? error.message : String(error)))