Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ command.
**From inside your agent** — install the hub's own plugin and let the agent do it:

```sh
dsh plugin --profile web add github:stvlynn/dsh.fish#main
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
```

It registers four tools: `hub_search`, `hub_show`, `hub_install` and
Expand Down
19 changes: 19 additions & 0 deletions backend/src/domain/artifact/install-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,25 @@ describe('buildInstallPlan', () => {
expect(unpinned.warningKeys).toContain('install.warning.unpinnedGitSpec')
})

it('selects a subdirectory bundle with pnpm\'s git path selector', () => {
const plan = buildInstallPlan(
artifact(
'bundle',
{ kind: 'bundle', requiresBuild: true },
githubSource({
owner: 'stvlynn',
repo: 'dsh.fish',
path: 'packages/dsh-plugin-hub',
commit: 'c'.repeat(40),
}),
),
target,
)
expect(plan.manualCommands[0]).toBe(
`dsh plugin --profile web add github:stvlynn/dsh.fish#${'c'.repeat(40)}&path:packages/dsh-plugin-hub`,
)
})

it('adds every profile bundle in declared order', () => {
const plan = buildInstallPlan(
artifact('profile', {
Expand Down
43 changes: 43 additions & 0 deletions backend/src/domain/artifact/source-ref.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { githubSource, npmSource, packageSpec } from './source-ref.js'

describe('packageSpec', () => {
it('pins an npm package to its latest version', () => {
expect(packageSpec(npmSource('dsh-example', '1.2.3'))).toBe('dsh-example@1.2.3')
})

it('emits a git host spec for a repository root', () => {
expect(packageSpec(githubSource({ owner: 'acme', repo: 'thing' }))).toBe(
'github:acme/thing',
)
})

it('pins a commit when the registry knows one', () => {
const commit = 'a'.repeat(40)
expect(packageSpec(githubSource({ owner: 'acme', repo: 'thing', commit }))).toBe(
`github:acme/thing#${commit}`,
)
})

it('selects a subdirectory package the way pnpm git installs require', () => {
expect(
packageSpec(
githubSource({ owner: 'stvlynn', repo: 'dsh.fish', path: 'packages/dsh-plugin-hub' }),
),
).toBe('github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub')
})

it('combines a pinned commit with a subdirectory selector', () => {
const commit = 'b'.repeat(40)
expect(
packageSpec(
githubSource({
owner: 'stvlynn',
repo: 'dsh.fish',
path: 'packages/dsh-plugin-hub',
commit,
}),
),
).toBe(`github:stvlynn/dsh.fish#${commit}&path:packages/dsh-plugin-hub`)
})
})
14 changes: 10 additions & 4 deletions backend/src/domain/artifact/source-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,16 @@ export function packageSpec(source: SourceRef): string | undefined {
switch (source.origin) {
case 'npm':
return `${source.packageName}@${source.latestVersion}`
case 'github':
return source.commit === undefined
? `github:${source.owner}/${source.repo}`
: `github:${source.owner}/${source.repo}#${source.commit}`
case 'github': {
const name = `github:${source.owner}/${source.repo}`
const selectors: string[] = []
if (source.commit !== undefined) selectors.push(source.commit)
// pnpm's git protocol: `#<commit>&path:<dir>` selects a workspace
// package inside the clone. Omitting `path` installs the repository
// root, which for a monorepo is usually not the bundle.
if (source.path !== undefined) selectors.push(`path:${source.path}`)
return selectors.length === 0 ? name : `${name}#${selectors.join('&')}`
}
case 'submission':
return undefined
}
Expand Down
12 changes: 8 additions & 4 deletions docs/decisions/adr-0001-plugin-hub-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,13 @@ never reads, which is how a registry ends up with an empty long tail.
variant, one `buildInstallPlan` branch, one installer branch, and message keys.
Nothing else changes.
- The `dsh-hub` plugin binds to `@deepseek-ai/dsh-tools` as a peer dependency and
declares its types locally, because that package is not yet installable
standalone from npm during the harness's developer preview. When it publishes
completely, `packages/dsh-plugin-hub/src/harness.d.ts` should be deleted and
the real packages added as devDependencies.
declares its types locally, because that package is not yet installable
standalone from npm during the harness's developer preview. When it publishes
completely, `packages/dsh-plugin-hub/src/harness.d.ts` should be deleted and
the real packages added as devDependencies. The plugin's `Config` export is a
Standard Schema (`~standard`), not a defaults object: Cordis rejects a plain
object and the plugin would not start. Git installs must name the subdirectory
package (`github:owner/repo#path:packages/dsh-plugin-hub`); the repository root
is the website, not the bundle.
- D1 has no cross-statement transactions, so multi-table writes use `db.batch`,
which D1 applies atomically.
7 changes: 7 additions & 0 deletions docs/project/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ yields nothing — the harness would load nothing from it either.
| `SKILL.md` with `name` + `description` frontmatter | `skill` |
| `agent.cordis.yml` | `agent-preset` |

Those probes run against the repository root (or an explicit subdirectory on
submit). A monorepo whose bundle lives under `packages/` — this project's own
`dsh-hub` plugin included — is therefore submitted as
`github:<owner>/<repo>/<path>`, and `packageSpec` emits pnpm's
`#path:<dir>` selector so `dsh plugin add` installs that package rather than
the root.

Those probes run before anything else is fetched, so a repository that is not a
plugin costs three reads and no API quota — that ordering is what makes it
affordable to page deep into a topic of several thousand repositories.
Expand Down
34 changes: 27 additions & 7 deletions frontend/src/pages/device/device-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,34 @@ export default function DevicePage() {
const [status, setStatus] = useState<OTPStatus>('idle')
const [busy, setBusy] = useState(false)

// A prefilled complete-URI visit should land on the confirmation step rather
// than making the user re-type a code the link already carried.
// Better Auth binds the pending code to this session on GET /device. Approve
// and deny refuse an unclaimed code, so a complete code — typed or prefilled
// — has to be claimed before the confirmation step is shown.
useEffect(() => {
if (code.length === CODE_LENGTH && phase === 'entering') setPhase('confirming')
// Runs once for the prefilled case; later transitions are driven by input.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (!session?.user || code.length !== CODE_LENGTH || phase !== 'entering') return
let cancelled = false
setBusy(true)
void authClient
.device({ query: { user_code: code } })
.then((result) => {
if (cancelled) return
if (result.error) {
setStatus('error')
return
}
setStatus('idle')
setPhase('confirming')
})
.catch(() => {
if (!cancelled) setStatus('error')
})
.finally(() => {
if (!cancelled) setBusy(false)
})
return () => {
cancelled = true
}
}, [session?.user, code, phase])

if (isPending) {
return <Shell>{t('common.loading')}</Shell>
Expand Down Expand Up @@ -120,7 +141,6 @@ export default function DevicePage() {
if (status !== 'idle') setStatus('idle')
if (value.length < CODE_LENGTH) setPhase('entering')
}}
onComplete={() => setPhase('confirming')}
/>
</div>

Expand Down
8 changes: 8 additions & 0 deletions frontend/src/shared/config/hub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* pnpm git specifier for the hub bundle.
*
* The bundle lives in `packages/dsh-plugin-hub`, not at the repository root.
* `github:stvlynn/dsh.fish#main` installs the website package (`dsh-fish`),
* which declares no `dsh.bundle`, so the harness activates no layer.
*/
export const HUB_PLUGIN_SPEC = 'github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub'
3 changes: 1 addition & 2 deletions frontend/src/widgets/install-panel/install-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ import { AnimatePresence, motion } from 'motion/react'
import { AlertTriangle, Check, Copy } from 'lucide-react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/motion/tabs'
import type { ArtifactDetail, InstallPlanDto } from '@/entities/artifact/model/types'
import { HUB_PLUGIN_SPEC } from '@/shared/config/hub'
import { t } from '@/shared/config/messages'
import { cn } from '@/shared/lib/utils'

const HUB_PLUGIN_SPEC = 'github:stvlynn/dsh.fish#main'

/**
* The install surface — the reason the site exists.
*
Expand Down
19 changes: 14 additions & 5 deletions packages/dsh-plugin-hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,18 @@ your agent.
## Install

```sh
dsh plugin --profile web add github:stvlynn/dsh.fish#main
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
```

The bundle lives in `packages/dsh-plugin-hub`. Installing the repository root
(`github:stvlynn/dsh.fish#main`) pulls the website package, which is not a
harness bundle and will not load.

A GitHub topic crawl only reads the repository root `package.json`, so this
bundle is not discovered automatically. Submit it as
`github:stvlynn/dsh.fish/packages/dsh-plugin-hub` (or the same path on npm
later) for it to appear in the catalog.

This package is TypeScript, so a git install runs its `prepare` script to build
`lib/`. pnpm ≥10 refuses that until you allow it — copy the package key pnpm
prints into your profile's `pnpm-workspace.yaml`:
Expand Down Expand Up @@ -36,10 +45,10 @@ so a later push cannot change what runs.
Reading the catalog needs no account. Signing in attributes installs to you and
is required for anything account-shaped later.

`hub_account` with `action: "login"` starts an RFC 8628 device grant: the plugin
requests a code, shows you a URL, and polls until you approve in a browser. The
token is written to `$DSH_HOME/.dsh-fish-token.json` with mode 0600 and is never
logged.
`hub_account` with `action: "login"` starts an RFC 8628 device grant. The first
call returns a short code and a URL — show those to the user. The second call
polls until they approve in a browser. The token is written to
`$DSH_HOME/.dsh-fish-token.json` with mode 0600 and is never logged.

A device token is deliberately weaker than a browser session — it can read the
catalog and resolve install plans as you, but it cannot submit or claim
Expand Down
2 changes: 1 addition & 1 deletion packages/dsh-plugin-hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"scripts": {
"build": "tsdown",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run --passWithNoTests",
"test": "vitest run",
"prepare": "tsdown"
},
"peerDependencies": {
Expand Down
30 changes: 30 additions & 0 deletions packages/dsh-plugin-hub/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { Config, DEFAULT_CONFIG } from './config.js'

describe('Config schema', () => {
it('implements Standard Schema so Cordis can validate the row', () => {
expect(Config['~standard']?.version).toBe(1)
expect(typeof Config['~standard']?.validate).toBe('function')
})

it('fills defaults when the row omits config', () => {
expect(Config['~standard'].validate(undefined)).toEqual({ value: DEFAULT_CONFIG })
expect(Config['~standard'].validate({})).toEqual({ value: DEFAULT_CONFIG })
})

it('accepts a self-hosted origin and a named profile', () => {
expect(
Config['~standard'].validate({
baseUrl: 'https://hub.example/',
targetProfile: 'headless',
}),
).toEqual({
value: { baseUrl: 'https://hub.example', targetProfile: 'headless' },
})
})

it('rejects a non-object row', () => {
const result = Config['~standard'].validate('https://dsh.fish')
expect('issues' in result).toBe(true)
})
})
71 changes: 71 additions & 0 deletions packages/dsh-plugin-hub/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Plugin configuration, as Cordis requires it: a Standard Schema, not a
* plain defaults object. A plain object does not implement `~standard`, so
* the loader cannot validate the row and the plugin fails to start.
*
* Schemastery is the usual authoring API in first-party plugins, but it is
* not installable standalone during the harness developer preview (the same
* constraint as `@deepseek-ai/dsh-tools`). Cordis accepts any Standard Schema
* validator, so this file implements that interface for the two fields the
* plugin actually reads.
*/

export interface Config {
/** Registry origin. A self-hosted deployment only needs this changed. */
baseUrl: string
/**
* Profile installs are written into. `current` resolves to the profile this
* harness booted with, which is almost always what a user means.
*/
targetProfile: string
}

export const DEFAULT_CONFIG: Config = {
baseUrl: 'https://dsh.fish',
targetProfile: 'current',
}

interface StandardIssue {
readonly message: string
}

type StandardResult<T> =
| { readonly value: T; readonly issues?: undefined }
| { readonly issues: readonly StandardIssue[] }

export const Config = {
'~standard': {
version: 1 as const,
vendor: 'dsh-hub',
validate(value: unknown): StandardResult<Config> {
if (value === undefined || value === null) {
return { value: { ...DEFAULT_CONFIG } }
}
if (typeof value !== 'object' || Array.isArray(value)) {
return { issues: [{ message: 'config must be an object' }] }
}
const input = value as Record<string, unknown>
const baseUrl = readString(input['baseUrl'], 'baseUrl')
if (typeof baseUrl !== 'string') return { issues: [{ message: baseUrl.message }] }
const targetProfile = readString(input['targetProfile'], 'targetProfile')
if (typeof targetProfile !== 'string') {
return { issues: [{ message: targetProfile.message }] }
}
return {
value: {
baseUrl: baseUrl === '' ? DEFAULT_CONFIG.baseUrl : baseUrl.replace(/\/+$/, ''),
targetProfile: targetProfile === '' ? DEFAULT_CONFIG.targetProfile : targetProfile,
},
}
},
},
}

function readString(
value: unknown,
field: string,
): string | { message: string } {
if (value === undefined) return ''
if (typeof value !== 'string') return { message: `${field} must be a string` }
return value.trim()
}
23 changes: 22 additions & 1 deletion packages/dsh-plugin-hub/src/hub-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,23 @@ export class HubClient {

/** Step one of the device grant: ask for a code pair. */
async requestDeviceCode(): Promise<DeviceCodeGrant> {
return this.request('/api/auth/device/code', {
const grant = await this.request<DeviceCodeGrant>('/api/auth/device/code', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ client_id: CLIENT_ID, scope: 'openid profile email' }),
})
return {
...grant,
verification_uri: absoluteUrl(this.baseUrl, grant.verification_uri),
...(grant.verification_uri_complete === undefined
? {}
: {
verification_uri_complete: absoluteUrl(
this.baseUrl,
grant.verification_uri_complete,
),
}),
}
}

/**
Expand Down Expand Up @@ -201,6 +213,15 @@ export class HubClient {
}
}

/** Resolve a possibly-relative verification URI against the hub origin. */
export function absoluteUrl(baseUrl: string, uri: string): string {
try {
return new URL(uri).toString()
} catch {
return new URL(uri, `${baseUrl.replace(/\/+$/, '')}/`).toString()
}
}

function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
Expand Down
Loading
Loading