feat(dsh): --openspec добавляет agents-адаптер в проект (#42)

Co-Authored-By: DeepSeek Harness <noreply@deepseek.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 19:26:36 +04:00
parent 5d0fdc4c0d
commit 748f8e115a
2 changed files with 69 additions and 2 deletions

View File

@ -13,6 +13,7 @@
# bash dsh/install.sh --skills # bash dsh/install.sh --skills
# bash dsh/install.sh --roles # bash dsh/install.sh --roles
# bash dsh/install.sh --all # bash dsh/install.sh --all
# bash dsh/install.sh --openspec
# #
# Run it from a terminal, not from inside a DSH session: --global, --skills and # Run it from a terminal, not from inside a DSH session: --global, --skills and
# --roles write outside the session workspace and would require approval. # --roles write outside the session workspace and would require approval.
@ -25,16 +26,18 @@ DO_CHECK=0
DO_GLOBAL=0 DO_GLOBAL=0
DO_SKILLS=0 DO_SKILLS=0
DO_ROLES=0 DO_ROLES=0
DO_OPENSPEC=0
usage() { usage() {
cat <<EOF cat <<EOF
Usage: bash dsh/install.sh [--check] [--global] [--skills] [--roles] [--all] Usage: bash dsh/install.sh [--check] [--global] [--skills] [--roles] [--all] [--openspec]
--check verify python3, git, node, hooks, bundle, skills, and roles --check verify python3, git, node, hooks, bundle, skills, and roles
--global back up and write \$DSH_HOME/AGENTS.md from global/CLAUDE.md --global back up and write \$DSH_HOME/AGENTS.md from global/CLAUDE.md
--skills copy superpowers skills and local dsh/skills into \$DSH_HOME/skills --skills copy superpowers skills and local dsh/skills into \$DSH_HOME/skills
--roles copy roles/ into \$DSH_HOME/roles --roles copy roles/ into \$DSH_HOME/roles
--all --check + --global + --skills + --roles, then print the bundle step --all --check + --global + --skills + --roles, then print the bundle step
--openspec add the agents skill adapter to the current OpenSpec project
Bundle installation itself is done in DSH: Bundle installation itself is done in DSH:
Plugins -> install a bundle from $BUNDLE Plugins -> install a bundle from $BUNDLE
@ -52,6 +55,7 @@ for arg in "$@"; do
--global) DO_GLOBAL=1 ;; --global) DO_GLOBAL=1 ;;
--skills) DO_SKILLS=1 ;; --skills) DO_SKILLS=1 ;;
--roles) DO_ROLES=1 ;; --roles) DO_ROLES=1 ;;
--openspec) DO_OPENSPEC=1 ;;
--all) DO_CHECK=1; DO_GLOBAL=1; DO_SKILLS=1; DO_ROLES=1 ;; --all) DO_CHECK=1; DO_GLOBAL=1; DO_SKILLS=1; DO_ROLES=1 ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;; *) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;;
@ -278,6 +282,20 @@ print("ok: role dsh notes inserted")
PY PY
} }
install_openspec() {
local dir="${1:-$PWD}"
if ! command -v openspec >/dev/null 2>&1; then
echo "FAIL: openspec CLI not found; install it first" >&2
return 1
fi
if [ ! -d "$dir/openspec" ]; then
echo "FAIL: $dir has no openspec/ root; run: openspec init --tools agents" >&2
return 1
fi
( cd "$dir" && openspec init --tools agents )
echo "ok: openspec agents adapter in $dir"
}
print_bundle_step() { print_bundle_step() {
cat <<EOF cat <<EOF
@ -307,6 +325,11 @@ if [ "$DO_ROLES" = 1 ]; then
install_roles install_roles
install_role_dsh_notes install_role_dsh_notes
fi fi
# A failed --openspec step must not abort the other steps: the explicit return 1
# becomes the script status here, like check() above.
if [ "$DO_OPENSPEC" = 1 ]; then
install_openspec "$PWD" || status=1
fi
if [ "$DO_GLOBAL" = 1 ] || [ "$DO_SKILLS" = 1 ] || [ "$DO_ROLES" = 1 ]; then if [ "$DO_GLOBAL" = 1 ] || [ "$DO_SKILLS" = 1 ] || [ "$DO_ROLES" = 1 ]; then
print_bundle_step print_bundle_step
fi fi

View File

@ -1,6 +1,6 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process' import { spawnSync } from 'node:child_process'
import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync } from 'node:fs' import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, symlinkSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
@ -66,3 +66,47 @@ test('install.sh --check warns but does not fail without the openspec CLI', () =
cleanup(home) cleanup(home)
} }
}) })
function fakeOpenspec(dir, log) {
const bin = join(dir, 'bin')
write(join(bin, 'openspec'), `#!/bin/sh\necho "$PWD $@" >> ${JSON.stringify(log)}\nexit 0\n`)
chmodSync(join(bin, 'openspec'), 0o755)
return bin
}
test('--openspec adds the agents adapter to an existing OpenSpec project', () => {
const home = tempDir()
try {
const project = join(home, 'project')
mkdirSync(join(project, 'openspec'), { recursive: true })
// macOS: os.tmpdir() идёт через симлинк /var -> /private/var, а $PWD в шелле — уже реальный путь
const realProject = realpathSync(project)
const log = join(home, 'calls.log')
const bin = fakeOpenspec(home, log)
const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), PATH: `${bin}:${process.env.PATH}` }
const run = spawnSync('bash', [script, '--openspec'], { cwd: project, env, encoding: 'utf8' })
assert.equal(run.status, 0, run.stderr)
assert.match(run.stdout, /ok: openspec agents adapter in /)
assert.match(readFileSync(log, 'utf8'), new RegExp(`^${realProject} init --tools agents$`, 'm'))
} finally {
cleanup(home)
}
})
test('--openspec refuses a project without an openspec root', () => {
const home = tempDir()
try {
const project = join(home, 'plain')
mkdirSync(project, { recursive: true })
const log = join(home, 'calls.log')
const bin = fakeOpenspec(home, log)
const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), PATH: `${bin}:${process.env.PATH}` }
const run = spawnSync('bash', [script, '--openspec'], { cwd: project, env, encoding: 'utf8' })
assert.notEqual(run.status, 0)
assert.match(run.stdout + run.stderr, /has no openspec\/ root/)
assert.equal(existsSync(log), false, 'openspec must not run in a non-OpenSpec project')
assert.equal(existsSync(join(project, 'openspec')), false)
} finally {
cleanup(home)
}
})