@plainconceptsplatform/agent-harness 2.0.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 +21 -0
- package/README.md +419 -0
- package/package.json +68 -0
- package/src/commands/join.js +244 -0
- package/src/commands/shared.js +27 -0
- package/src/commands/single.js +79 -0
- package/src/commands/update.js +109 -0
- package/src/commands/wizard.js +134 -0
- package/src/content/.agents/skills/browser-automation/SKILL.md +66 -0
- package/src/content/.agents/skills/pc-guardrails-generic/SKILL.md +68 -0
- package/src/content/.agents/skills/pc-guardrails-project/SKILL.md +8 -0
- package/src/content/.agents/skills/pc-make-architecture/SKILL.md +51 -0
- package/src/content/.agents/skills/pc-make-architecture/structure-template.md +38 -0
- package/src/content/.agents/skills/pc-make-design/SKILL.md +68 -0
- package/src/content/.agents/skills/pc-make-engineer/SKILL.md +219 -0
- package/src/content/.agents/skills/pc-make-engineer/signal-mapping.md +68 -0
- package/src/content/.agents/skills/pc-make-engineer/template.md +81 -0
- package/src/content/.agents/skills/pc-make-evidence-scaffold/SKILL.md +18 -0
- package/src/content/.agents/skills/pc-make-evidence-scaffold/evidence-contract.md +29 -0
- package/src/content/.agents/skills/pc-make-guardrails/SKILL.md +74 -0
- package/src/content/.agents/skills/pc-make-guardrails/category-reference.md +68 -0
- package/src/content/.agents/skills/pc-make-merge-risk-assess/SKILL.md +70 -0
- package/src/content/.agents/skills/pc-make-merge-risk-assess/category-reference.md +98 -0
- package/src/content/.agents/skills/pc-make-user-model/SKILL.md +66 -0
- package/src/content/.agents/skills/pc-ops-evidence/SKILL.md +127 -0
- package/src/content/.agents/skills/pc-ops-ship/SKILL.md +18 -0
- package/src/content/.agents/skills/pc-plan-apply/SKILL.md +83 -0
- package/src/content/.agents/skills/pc-plan-apply/simple-mode.md +21 -0
- package/src/content/.agents/skills/pc-plan-archive/SKILL.md +63 -0
- package/src/content/.agents/skills/pc-plan-explore/SKILL.md +9 -0
- package/src/content/.agents/skills/pc-plan-goal/SKILL.md +94 -0
- package/src/content/.agents/skills/pc-plan-goal/branching.md +30 -0
- package/src/content/.agents/skills/pc-plan-goal/failure-policy.md +30 -0
- package/src/content/.agents/skills/pc-plan-goal/output-mode.md +9 -0
- package/src/content/.agents/skills/pc-plan-goal/output.md +68 -0
- package/src/content/.agents/skills/pc-plan-propose/SKILL.md +125 -0
- package/src/content/.agents/skills/pc-plan-propose/task-annotation.md +39 -0
- package/src/content/.agents/skills/pc-plan-quick/SKILL.md +62 -0
- package/src/content/.agents/skills/pc-plan-story/SKILL.md +146 -0
- package/src/content/.agents/skills/pc-repo-audit/SKILL.md +44 -0
- package/src/content/.agents/skills/pc-repo-help/SKILL.md +91 -0
- package/src/content/.agents/skills/pc-repo-initialize/SKILL.md +130 -0
- package/src/content/.agents/skills/pc-repo-onboard/SKILL.md +87 -0
- package/src/content/.agents/skills/pc-repo-verify/SKILL.md +34 -0
- package/src/content/.agents/skills/pc-userstory-az/SKILL.md +157 -0
- package/src/content/.agents/skills/pc-userstory-browser/SKILL.md +132 -0
- package/src/content/.agents/skills/pc-userstory-gh/SKILL.md +120 -0
- package/src/content/.agents/skills/pc-userstory-jira/SKILL.md +131 -0
- package/src/content/.opencode/_gitignore +7 -0
- package/src/content/.opencode/commands/init.md +5 -0
- package/src/content/.opencode/commands/make-architecture.md +5 -0
- package/src/content/.opencode/commands/make-design.md +5 -0
- package/src/content/.opencode/commands/make-engineer.md +5 -0
- package/src/content/.opencode/commands/make-evidence-scaffold.md +5 -0
- package/src/content/.opencode/commands/make-guardrails.md +5 -0
- package/src/content/.opencode/commands/make-user-model.md +5 -0
- package/src/content/.opencode/commands/ops-backlog.md +10 -0
- package/src/content/.opencode/commands/ops-evidence.md +9 -0
- package/src/content/.opencode/commands/ops-review.md +8 -0
- package/src/content/.opencode/commands/ops-ship.md +9 -0
- package/src/content/.opencode/commands/plan-apply.md +9 -0
- package/src/content/.opencode/commands/plan-archive.md +5 -0
- package/src/content/.opencode/commands/plan-explore.md +9 -0
- package/src/content/.opencode/commands/plan-goal.md +5 -0
- package/src/content/.opencode/commands/plan-propose.md +9 -0
- package/src/content/.opencode/commands/plan-quick.md +5 -0
- package/src/content/.opencode/commands/plan-story.md +9 -0
- package/src/content/.opencode/commands/repo-audit.md +5 -0
- package/src/content/.opencode/commands/repo-help.md +5 -0
- package/src/content/.opencode/commands/repo-initialize.md +5 -0
- package/src/content/.opencode/commands/repo-onboard.md +5 -0
- package/src/content/.opencode/commands/repo-verify.md +5 -0
- package/src/content/.opencode/package.json +10 -0
- package/src/content/.opencode/plugins/pc-subagent-monitor.js +139 -0
- package/src/content/.opencode/plugins/pc-subagent-tiers.js +179 -0
- package/src/content/.opencode/plugins/pc-system-reminders.js +96 -0
- package/src/content/.opencode/plugins/pc-system-reminders.test.js +35 -0
- package/src/content/.opencode/tui/pc-subagents.tsx +98 -0
- package/src/content/.opencode/tui.json +6 -0
- package/src/content/AGENTS.md +71 -0
- package/src/content/ARCHITECTURE.md +16 -0
- package/src/content/DESIGN.md +16 -0
- package/src/content/opencode.jsonc +31 -0
- package/src/content/openspec/changes/archive/.gitkeep +0 -0
- package/src/content/openspec/config.yaml +20 -0
- package/src/content/openspec/specs/.gitkeep +0 -0
- package/src/content/skills-lock.json +17 -0
- package/src/fragments/archive/az.md +95 -0
- package/src/fragments/archive/gh.md +94 -0
- package/src/fragments/archive/gl.md +94 -0
- package/src/fragments/archive/none.md +73 -0
- package/src/fragments/guardrails/codegraph.md +7 -0
- package/src/fragments/guardrails/humanizer.md +4 -0
- package/src/fragments/guardrails/memory.md +4 -0
- package/src/fragments/guardrails/rtk.md +3 -0
- package/src/fragments/guardrails/simple-english.md +4 -0
- package/src/fragments/ops-backlog/az.md +29 -0
- package/src/fragments/ops-backlog/gh.md +30 -0
- package/src/fragments/ops-backlog/jira.md +29 -0
- package/src/fragments/ops-evidence/az.md +41 -0
- package/src/fragments/ops-evidence/gh.md +53 -0
- package/src/fragments/ops-evidence/jira.md +38 -0
- package/src/fragments/ops-review/az.md +63 -0
- package/src/fragments/ops-review/gh.md +53 -0
- package/src/fragments/ops-review/gl.md +57 -0
- package/src/fragments/ops-ship/az.md +81 -0
- package/src/fragments/ops-ship/gh.md +69 -0
- package/src/fragments/ops-ship/gl.md +86 -0
- package/src/index.js +107 -0
- package/src/presets/agents-content.json +53 -0
- package/src/presets/browser.json +22 -0
- package/src/presets/clean.json +21 -0
- package/src/presets/models.json +68 -0
- package/src/presets/openspec.json +1 -0
- package/src/presets/optimization.json +37 -0
- package/src/presets/platforms.json +76 -0
- package/src/presets/quota.json +16 -0
- package/src/presets/source.json +23 -0
- package/src/steps/browser/index.js +91 -0
- package/src/steps/clean/index.js +120 -0
- package/src/steps/copy/agents.js +118 -0
- package/src/steps/copy/commands.js +91 -0
- package/src/steps/copy/fullstack-engineer.js +83 -0
- package/src/steps/copy/index.js +88 -0
- package/src/steps/copy/opencode-json.js +129 -0
- package/src/steps/copy/skills.js +196 -0
- package/src/steps/metadata/index.js +108 -0
- package/src/steps/models/format.js +88 -0
- package/src/steps/models/index.js +64 -0
- package/src/steps/models/write.js +34 -0
- package/src/steps/openspec/index.js +136 -0
- package/src/steps/optimization/codegraph.js +127 -0
- package/src/steps/optimization/humanizer.js +17 -0
- package/src/steps/optimization/index.js +163 -0
- package/src/steps/optimization/memory.js +88 -0
- package/src/steps/optimization/patch-guardrails.js +108 -0
- package/src/steps/optimization/quota.js +119 -0
- package/src/steps/optimization/simple-english.js +17 -0
- package/src/steps/optimization/skills-lock.js +30 -0
- package/src/steps/platform/index.js +109 -0
- package/src/steps/source/index.js +123 -0
- package/src/utils/copy.js +108 -0
- package/src/utils/exec-spinner.js +47 -0
- package/src/utils/exec.js +134 -0
- package/src/utils/legacy-check.js +30 -0
- package/src/utils/models-cache.js +58 -0
- package/src/utils/models-pricing.js +42 -0
- package/src/utils/paths.js +64 -0
- package/src/utils/process.js +3 -0
- package/src/utils/terminal.js +6 -0
- package/src/utils/update-manifest.js +49 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts'
|
|
2
|
+
import { execa } from 'execa'
|
|
3
|
+
import fse from 'fs-extra'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
import { fileURLToPath } from 'url'
|
|
6
|
+
import { code, commandExists, header, info, success, warn } from '../../utils/exec.js'
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
const PLATFORMS_PRESET_PATH = path.resolve(__dirname, '../../presets/platforms.json')
|
|
10
|
+
const platformsPreset = await fse.readJson(PLATFORMS_PRESET_PATH)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
export async function checkPlatform(platform) {
|
|
14
|
+
const preset = platformsPreset.find(p => p.value === platform) || platformsPreset[0]
|
|
15
|
+
if (!preset.cli) {
|
|
16
|
+
header(`Step 4, Checking ${preset.name} CLI`)
|
|
17
|
+
info('No platform integration selected, skipping CLI checks.')
|
|
18
|
+
success(`Platform: ${preset.name}`)
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
await checkPlatformCli(preset)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function choosePlatform() {
|
|
25
|
+
header('Step 3, Version control platform')
|
|
26
|
+
|
|
27
|
+
const backlogChoices = platformsPreset.filter(p => !p.repoOnly)
|
|
28
|
+
const backlogPlatform = await select({
|
|
29
|
+
message: 'Where is your backlog (work items / issues)?',
|
|
30
|
+
choices: backlogChoices.map(p => ({ name: p.name, value: p.value })),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const backlogPreset = platformsPreset.find(p => p.value === backlogPlatform)
|
|
34
|
+
success(`Backlog platform: ${backlogPreset?.name || backlogPlatform}`)
|
|
35
|
+
|
|
36
|
+
let repoPlatform = backlogPlatform
|
|
37
|
+
if (backlogPlatform !== 'none') {
|
|
38
|
+
// Exclude backlog-only platforms (e.g. Jira) from repo choices
|
|
39
|
+
const repoChoices = platformsPreset.filter(p => !p.backlogOnly)
|
|
40
|
+
repoPlatform = await select({
|
|
41
|
+
message: 'Where is your code repository (PRs / code reviews)?',
|
|
42
|
+
choices: repoChoices.map(p => ({ name: p.name, value: p.value })),
|
|
43
|
+
default: backlogPreset.backlogOnly ? 'github' : backlogPlatform,
|
|
44
|
+
})
|
|
45
|
+
const repoPreset = platformsPreset.find(p => p.value === repoPlatform)
|
|
46
|
+
success(`Repo platform: ${repoPreset?.name || repoPlatform}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check CLIs for both platforms (deduped)
|
|
50
|
+
const platformsToCheck = [...new Set([backlogPlatform, repoPlatform])].filter(p => p !== 'none')
|
|
51
|
+
for (const p of platformsToCheck) {
|
|
52
|
+
const preset = platformsPreset.find(x => x.value === p)
|
|
53
|
+
if (preset?.cli) await checkPlatformCli(preset)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { backlogPlatform, repoPlatform }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function checkPlatformCli(platformPreset) {
|
|
60
|
+
const { cli } = platformPreset
|
|
61
|
+
header(`Step 4, Checking ${platformPreset.name} CLI`)
|
|
62
|
+
|
|
63
|
+
const hasCli = await commandExists(cli.command)
|
|
64
|
+
if (!hasCli) {
|
|
65
|
+
warn(`${cli.displayName} not found.`)
|
|
66
|
+
info(`Install from: ${cli.installUrl}`)
|
|
67
|
+
if (cli.authCheck?.commands?.length) {
|
|
68
|
+
console.log()
|
|
69
|
+
info('After installing, authenticate with:')
|
|
70
|
+
code(cli.authCheck.commands)
|
|
71
|
+
}
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
success(`${cli.displayName} available`)
|
|
75
|
+
|
|
76
|
+
if (cli.authCheck) await runAuthCheck(cli)
|
|
77
|
+
if (cli.extensionCheck) await runExtensionCheck(cli)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function runAuthCheck(cli) {
|
|
81
|
+
try {
|
|
82
|
+
const result = await execa(cli.command, cli.authCheck.args, { reject: false })
|
|
83
|
+
if (result.exitCode === 0) {
|
|
84
|
+
success(`${cli.displayName} authenticated`)
|
|
85
|
+
} else {
|
|
86
|
+
warn(cli.authCheck.notAuthenticatedMessage)
|
|
87
|
+
code(cli.authCheck.commands)
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
warn(`Could not check ${cli.command} auth status.`)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function runExtensionCheck(cli) {
|
|
95
|
+
try {
|
|
96
|
+
const result = await execa(cli.command, cli.extensionCheck.args, { reject: false })
|
|
97
|
+
const hasExtension = result.stdout && result.stdout.includes(cli.extensionCheck.expectedOutput)
|
|
98
|
+
|
|
99
|
+
if (hasExtension) {
|
|
100
|
+
success(`${cli.extensionCheck.expectedOutput} extension installed`)
|
|
101
|
+
} else {
|
|
102
|
+
warn(cli.extensionCheck.missingMessage)
|
|
103
|
+
code(cli.extensionCheck.commands)
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
warn(cli.extensionCheck.errorMessage)
|
|
107
|
+
code(cli.extensionCheck.commands)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { checkbox, select } from '@inquirer/prompts'
|
|
2
|
+
import fse from 'fs-extra'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { header, info, success, warn } from '../../utils/exec.js'
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const SOURCE_PRESET_PATH = path.resolve(__dirname, '../../presets/source.json')
|
|
9
|
+
const sourcePreset = await fse.readJson(SOURCE_PRESET_PATH)
|
|
10
|
+
|
|
11
|
+
async function listChildFolders(cwd) {
|
|
12
|
+
const entries = await fse.readdir(cwd)
|
|
13
|
+
const dirs = []
|
|
14
|
+
|
|
15
|
+
for (const name of entries) {
|
|
16
|
+
if (name.startsWith('.')) continue
|
|
17
|
+
const abs = path.join(cwd, name)
|
|
18
|
+
try {
|
|
19
|
+
const stat = await fse.stat(abs)
|
|
20
|
+
if (!stat.isDirectory()) continue
|
|
21
|
+
dirs.push({ name, abs })
|
|
22
|
+
} catch {
|
|
23
|
+
// ignore invalid entries
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
dirs.sort((a, b) => a.name.localeCompare(b.name))
|
|
28
|
+
return dirs
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function listParentFolders(cwd) {
|
|
32
|
+
const parent = path.resolve(cwd, '..')
|
|
33
|
+
const entries = await fse.readdir(parent)
|
|
34
|
+
const dirs = []
|
|
35
|
+
|
|
36
|
+
for (const name of entries) {
|
|
37
|
+
if (name.startsWith('.')) continue
|
|
38
|
+
const abs = path.join(parent, name)
|
|
39
|
+
try {
|
|
40
|
+
const stat = await fse.stat(abs)
|
|
41
|
+
if (!stat.isDirectory()) continue
|
|
42
|
+
if (path.resolve(abs) === path.resolve(cwd)) continue
|
|
43
|
+
dirs.push({ name, abs })
|
|
44
|
+
} catch {
|
|
45
|
+
// ignore invalid entries
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
dirs.sort((a, b) => a.name.localeCompare(b.name))
|
|
50
|
+
return dirs
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function chooseSourceScope() {
|
|
54
|
+
header('Step 1, Source code scope')
|
|
55
|
+
|
|
56
|
+
const cwd = process.cwd()
|
|
57
|
+
info('Choose where agents should read source code from during init analysis.')
|
|
58
|
+
|
|
59
|
+
const mode = await select({
|
|
60
|
+
message: sourcePreset.message,
|
|
61
|
+
default: sourcePreset.default,
|
|
62
|
+
choices: sourcePreset.choices,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
if (mode === 'current') {
|
|
66
|
+
success(`Source scope: ${cwd}`)
|
|
67
|
+
return { sourceMode: 'current', sourceRoots: [cwd] }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (mode === 'children') {
|
|
71
|
+
const childFolders = await listChildFolders(cwd)
|
|
72
|
+
if (childFolders.length === 0) {
|
|
73
|
+
warn('No child folders found in current directory. Falling back to current folder.')
|
|
74
|
+
success(`Source scope: ${cwd}`)
|
|
75
|
+
return { sourceMode: 'current', sourceRoots: [cwd] }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const selected = await checkbox({
|
|
79
|
+
message: sourcePreset.childrenSelectionMessage,
|
|
80
|
+
choices: childFolders.map(d => ({
|
|
81
|
+
name: `./${d.name}`,
|
|
82
|
+
value: d.abs,
|
|
83
|
+
checked: true,
|
|
84
|
+
})),
|
|
85
|
+
required: true,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
if (!selected || selected.length === 0) {
|
|
89
|
+
warn('No folders selected. Falling back to current folder.')
|
|
90
|
+
success(`Source scope: ${cwd}`)
|
|
91
|
+
return { sourceMode: 'current', sourceRoots: [cwd] }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
success(`Source scope: ${selected.map(p => path.basename(p)).join(', ')}`)
|
|
95
|
+
return { sourceMode: 'children-selected', sourceRoots: selected }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const parentFolders = await listParentFolders(cwd)
|
|
99
|
+
if (parentFolders.length === 0) {
|
|
100
|
+
warn('No sibling folders found in parent directory. Falling back to current folder.')
|
|
101
|
+
success(`Source scope: ${cwd}`)
|
|
102
|
+
return { sourceMode: 'current', sourceRoots: [cwd] }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const selected = await checkbox({
|
|
106
|
+
message: sourcePreset.parentSelectionMessage,
|
|
107
|
+
choices: parentFolders.map(d => ({
|
|
108
|
+
name: `../${d.name}`,
|
|
109
|
+
value: d.abs,
|
|
110
|
+
checked: true,
|
|
111
|
+
})),
|
|
112
|
+
required: true,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
if (!selected || selected.length === 0) {
|
|
116
|
+
warn('No folders selected. Falling back to current folder.')
|
|
117
|
+
success(`Source scope: ${cwd}`)
|
|
118
|
+
return { sourceMode: 'current', sourceRoots: [cwd] }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
success(`Source scope: ${selected.map(p => path.basename(p)).join(', ')}`)
|
|
122
|
+
return { sourceMode: 'parent-selected', sourceRoots: selected }
|
|
123
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import fse from 'fs-extra'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { canUpdateManagedFile, hashFile, readUpdateManifest, recordManagedFile, writeUpdateManifest } from './update-manifest.js'
|
|
4
|
+
|
|
5
|
+
// Folders never copied (skills handled separately by installSkills, .bootstrap is internal tooling)
|
|
6
|
+
const ALWAYS_EXCLUDE = ['.bootstrap', 'skills', 'node_modules']
|
|
7
|
+
|
|
8
|
+
// Files never overwritten even with forceOverwrite: user owns these.
|
|
9
|
+
// The CLI ships templates, but init / the wizard / the user populate them
|
|
10
|
+
// with project-specific content. Overwriting on update would destroy user work.
|
|
11
|
+
const NEVER_OVERWRITE = [
|
|
12
|
+
`openspec${path.sep}config.yaml`,
|
|
13
|
+
'opencode.jsonc',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
const MARKER_COMMANDS = new Set(['.opencode/commands/ops-review.md', '.opencode/commands/ops-backlog.md'])
|
|
17
|
+
|
|
18
|
+
function isTemplateTest(relativePath) {
|
|
19
|
+
return relativePath.split(path.sep).join('/').startsWith('.opencode/plugins/') && relativePath.endsWith('.test.js')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isManagedContentPath(relativePath) {
|
|
23
|
+
const normalized = relativePath.split(path.sep).join('/')
|
|
24
|
+
if (normalized.startsWith('.opencode/plugins/')) return true
|
|
25
|
+
if (normalized === '.opencode/tui/pc-subagents.tsx') return true
|
|
26
|
+
if (normalized.startsWith('.opencode/commands/') && !MARKER_COMMANDS.has(normalized)) return true
|
|
27
|
+
return normalized === 'openspec/specs/.gitkeep' || normalized === 'openspec/changes/archive/.gitkeep'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Copy content/ directory to destination.
|
|
32
|
+
* Excludes:
|
|
33
|
+
* - .agents/skills and .opencode/skills (handled separately)
|
|
34
|
+
* - .bootstrap (internal tooling)
|
|
35
|
+
* - node_modules
|
|
36
|
+
* - DESIGN.md and ARCHITECTURE.md if ctx says they already exist (preserve user's files)
|
|
37
|
+
* @param {string} contentDir - absolute path to content/
|
|
38
|
+
* @param {string} destDir - absolute path to destination (project root)
|
|
39
|
+
* @param {'azure'|'github'} platform
|
|
40
|
+
* @param {{ hasDesign?: boolean, hasArchitecture?: boolean, updateMode?: boolean }} ctx
|
|
41
|
+
*/
|
|
42
|
+
export async function copyContent(contentDir, destDir, platform, ctx = {}) {
|
|
43
|
+
const manifest = ctx.updateMode ? await readUpdateManifest(destDir) : null
|
|
44
|
+
await fse.copy(contentDir, destDir, {
|
|
45
|
+
overwrite: ctx.updateMode || ctx.forceOverwrite || false,
|
|
46
|
+
filter: async (src) => {
|
|
47
|
+
const rel = path.relative(contentDir, src)
|
|
48
|
+
const parts = rel.split(path.sep)
|
|
49
|
+
if (parts.some(part => ALWAYS_EXCLUDE.includes(part))) return false
|
|
50
|
+
if (isTemplateTest(rel)) return false
|
|
51
|
+
if (ctx.hasDesign && rel === 'DESIGN.md') return false
|
|
52
|
+
if (ctx.hasArchitecture && rel === 'ARCHITECTURE.md') return false
|
|
53
|
+
// User-owned config files are never overwritten, even with forceOverwrite.
|
|
54
|
+
// The update command calls writeModelsToConfigs separately to set the
|
|
55
|
+
// model field in opencode.jsonc without destroying user additions.
|
|
56
|
+
if (NEVER_OVERWRITE.includes(rel)) return false
|
|
57
|
+
if (!ctx.updateMode) return true
|
|
58
|
+
|
|
59
|
+
const stat = await fse.stat(src)
|
|
60
|
+
if (stat.isDirectory()) return true
|
|
61
|
+
if (!isManagedContentPath(rel)) {
|
|
62
|
+
return !(await fse.pathExists(path.join(destDir, rel)))
|
|
63
|
+
}
|
|
64
|
+
return canUpdateManagedFile(rel, destDir, manifest)
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function recordManagedContent(contentDir, destDir, { updateMode = false } = {}) {
|
|
70
|
+
const manifest = await readUpdateManifest(destDir)
|
|
71
|
+
|
|
72
|
+
async function walk(directory) {
|
|
73
|
+
for (const entry of await fse.readdir(directory, { withFileTypes: true })) {
|
|
74
|
+
const sourcePath = path.join(directory, entry.name)
|
|
75
|
+
const relativePath = path.relative(contentDir, sourcePath)
|
|
76
|
+
if (entry.isDirectory()) {
|
|
77
|
+
await walk(sourcePath)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
if (isTemplateTest(relativePath)) continue
|
|
81
|
+
if (!isManagedContentPath(relativePath)) continue
|
|
82
|
+
|
|
83
|
+
const destinationPath = path.join(destDir, relativePath)
|
|
84
|
+
if (!await fse.pathExists(destinationPath)) continue
|
|
85
|
+
const sourceHash = await hashFile(sourcePath)
|
|
86
|
+
const destinationHash = await hashFile(destinationPath)
|
|
87
|
+
const manifestPath = relativePath.split(path.sep).join('/')
|
|
88
|
+
const previousHash = manifest.files?.[manifestPath]
|
|
89
|
+
if (!updateMode || destinationHash === sourceHash || destinationHash === previousHash) {
|
|
90
|
+
await recordManagedFile(manifest, relativePath, sourcePath)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await walk(contentDir)
|
|
96
|
+
await writeUpdateManifest(manifest, destDir)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function findAiFiles(dir, files) {
|
|
100
|
+
const found = []
|
|
101
|
+
for (const file of files) {
|
|
102
|
+
const fullPath = path.join(dir, file)
|
|
103
|
+
if (await fse.pathExists(fullPath)) {
|
|
104
|
+
found.push(fullPath)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return found
|
|
108
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import ora from 'ora'
|
|
3
|
+
|
|
4
|
+
// ── Screen / step state ──────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const previousSteps = []; // up to 2 completed steps, each is an array of lines
|
|
7
|
+
let currentStepLines = []; // lines accumulated in the current step
|
|
8
|
+
let stepSpinner = null; // ora spinner shown while step is working
|
|
9
|
+
|
|
10
|
+
export function appendLine(line) {
|
|
11
|
+
currentStepLines.push(line);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function stopSpinner() {
|
|
15
|
+
if (stepSpinner) {
|
|
16
|
+
stepSpinner.stop();
|
|
17
|
+
stepSpinner = null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function startSpinner(text = 'working...') {
|
|
22
|
+
stopSpinner();
|
|
23
|
+
stepSpinner = ora({ text: chalk.dim(text), color: 'red' }).start();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function redraw() {
|
|
27
|
+
if (process.stdout.isTTY) console.clear();
|
|
28
|
+
|
|
29
|
+
// Show up to 2 previous steps dimmed
|
|
30
|
+
for (const stepLines of previousSteps) {
|
|
31
|
+
for (const line of stepLines) {
|
|
32
|
+
process.stdout.write(`${chalk.dim(line)}\n`);
|
|
33
|
+
}
|
|
34
|
+
process.stdout.write('\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Current step output
|
|
38
|
+
for (const line of currentStepLines) {
|
|
39
|
+
process.stdout.write(`${line}\n`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function rotateStep() {
|
|
44
|
+
previousSteps.push(currentStepLines);
|
|
45
|
+
if (previousSteps.length > 2) previousSteps.shift();
|
|
46
|
+
currentStepLines = [];
|
|
47
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { execa } from 'execa'
|
|
3
|
+
import ora from 'ora'
|
|
4
|
+
import { appendLine, redraw, rotateStep, startSpinner, stopSpinner } from './exec-spinner.js'
|
|
5
|
+
import { MARKERS } from './terminal.js';
|
|
6
|
+
|
|
7
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run a shell command with a spinner.
|
|
11
|
+
* Returns { success, stdout, stderr }
|
|
12
|
+
*/
|
|
13
|
+
export async function run(command, args = [], { label, cwd = process.cwd() } = {}) {
|
|
14
|
+
const spinner = ora(label ?? `${command} ${args.join(' ')}`).start();
|
|
15
|
+
try {
|
|
16
|
+
const result = await execa(command, args, { cwd, reject: false });
|
|
17
|
+
if (result.exitCode === 0) {
|
|
18
|
+
spinner.succeed();
|
|
19
|
+
} else {
|
|
20
|
+
spinner.fail();
|
|
21
|
+
}
|
|
22
|
+
return { success: result.exitCode === 0, stdout: result.stdout, stderr: result.stderr };
|
|
23
|
+
} catch (err) {
|
|
24
|
+
spinner.fail();
|
|
25
|
+
return { success: false, stdout: '', stderr: err.message };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if a command is available on PATH.
|
|
31
|
+
* Returns true/false.
|
|
32
|
+
*/
|
|
33
|
+
export async function commandExists(command) {
|
|
34
|
+
try {
|
|
35
|
+
const result = await execa(command, ['--version'], { reject: false });
|
|
36
|
+
return result.exitCode === 0;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Print a section header, clears screen, shows previous step dimmed, starts new step.
|
|
44
|
+
*/
|
|
45
|
+
export function header(text) {
|
|
46
|
+
rotateStep();
|
|
47
|
+
|
|
48
|
+
const line1 = '';
|
|
49
|
+
const line2 = chalk.bold.hex('#fe3d57')(`━━ ${text}`);
|
|
50
|
+
const line3 = '';
|
|
51
|
+
|
|
52
|
+
appendLine(line1);
|
|
53
|
+
appendLine(line2);
|
|
54
|
+
appendLine(line3);
|
|
55
|
+
|
|
56
|
+
redraw();
|
|
57
|
+
|
|
58
|
+
startSpinner('working...');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Restart the step spinner after prompts or logs.
|
|
63
|
+
*/
|
|
64
|
+
export function loading(text = 'working...') {
|
|
65
|
+
startSpinner(text);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Print a success line.
|
|
70
|
+
*/
|
|
71
|
+
export function success(text) {
|
|
72
|
+
stopSpinner();
|
|
73
|
+
const line = chalk.green(`${MARKERS.OK_PREFIX}${text}`);
|
|
74
|
+
appendLine(line);
|
|
75
|
+
console.log(line);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Print a warning line.
|
|
80
|
+
*/
|
|
81
|
+
export function warn(text) {
|
|
82
|
+
stopSpinner();
|
|
83
|
+
const line = chalk.yellow(`${MARKERS.WARN_PREFIX}${text}`);
|
|
84
|
+
appendLine(line);
|
|
85
|
+
console.log(line);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Print an error line.
|
|
90
|
+
*/
|
|
91
|
+
export function error(text) {
|
|
92
|
+
stopSpinner();
|
|
93
|
+
const line = chalk.red(`${MARKERS.ERROR_PREFIX}${text}`);
|
|
94
|
+
appendLine(line);
|
|
95
|
+
console.log(line);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Print an info line.
|
|
100
|
+
*/
|
|
101
|
+
export function info(text) {
|
|
102
|
+
stopSpinner();
|
|
103
|
+
const line = chalk.dim(`${MARKERS.EMPTY}${text}`);
|
|
104
|
+
appendLine(line);
|
|
105
|
+
console.log(line);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Print an action prompt line (white bold, requires user interaction).
|
|
110
|
+
*/
|
|
111
|
+
export function prompt(text) {
|
|
112
|
+
stopSpinner();
|
|
113
|
+
const line = chalk.bold(`${MARKERS.EMPTY}${text}`);
|
|
114
|
+
appendLine(line);
|
|
115
|
+
console.log(line);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Print a code block.
|
|
120
|
+
*/
|
|
121
|
+
export function code(lines) {
|
|
122
|
+
stopSpinner();
|
|
123
|
+
appendLine('');
|
|
124
|
+
console.log();
|
|
125
|
+
for (const line of lines) {
|
|
126
|
+
const formatted = chalk.bgGray.white(
|
|
127
|
+
`${MARKERS.EMPTY}${line}${MARKERS.EMPTY}`,
|
|
128
|
+
);
|
|
129
|
+
appendLine(formatted);
|
|
130
|
+
console.log(formatted);
|
|
131
|
+
}
|
|
132
|
+
appendLine('');
|
|
133
|
+
console.log();
|
|
134
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fse from 'fs-extra'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { CONFIG_FILE, LEGACY_CONFIG_FILES, OPENCODE_DIR } from './paths.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detect a repo onboarded by opencode-onboard v1.x.
|
|
7
|
+
*
|
|
8
|
+
* v2 renamed both the state files and the `ob-` skill prefix, and it does not
|
|
9
|
+
* migrate: see the rename plan. Running against a v1 repo would otherwise fail
|
|
10
|
+
* silently in two places, because `patchFile` returns without logging when its
|
|
11
|
+
* markers are absent and the skill sync only removes stale directories under
|
|
12
|
+
* `forceOverwrite && !updateMode`. The result is a half-patched harness that
|
|
13
|
+
* still reports success, so refuse the run instead.
|
|
14
|
+
*/
|
|
15
|
+
export async function findLegacyInstall(cwd = process.cwd()) {
|
|
16
|
+
const opencodeDir = path.join(cwd, OPENCODE_DIR)
|
|
17
|
+
if (await fse.pathExists(path.join(opencodeDir, CONFIG_FILE))) return null
|
|
18
|
+
|
|
19
|
+
const found = []
|
|
20
|
+
for (const file of LEGACY_CONFIG_FILES) {
|
|
21
|
+
if (await fse.pathExists(path.join(opencodeDir, file))) found.push(`${OPENCODE_DIR}/${file}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const skillsDir = path.join(cwd, '.agents', 'skills')
|
|
25
|
+
const legacySkills = (await fse.readdir(skillsDir).catch(() => []))
|
|
26
|
+
.filter(entry => entry.startsWith('ob-'))
|
|
27
|
+
|
|
28
|
+
if (found.length === 0 && legacySkills.length === 0) return null
|
|
29
|
+
return { files: found, skills: legacySkills }
|
|
30
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fse from 'fs-extra'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { parseModels } from './models-pricing.js'
|
|
4
|
+
import { modelsCacheDir } from './paths.js'
|
|
5
|
+
|
|
6
|
+
const CACHE_DIR = modelsCacheDir();
|
|
7
|
+
const CACHE_FILE = path.join(CACHE_DIR, 'models-cache.json');
|
|
8
|
+
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
9
|
+
const MODELS_URL = 'https://models.dev/api.json';
|
|
10
|
+
|
|
11
|
+
async function loadCache() {
|
|
12
|
+
try {
|
|
13
|
+
if (!await fse.pathExists(CACHE_FILE)) return null;
|
|
14
|
+
const cache = await fse.readJson(CACHE_FILE);
|
|
15
|
+
if (!cache.timestamp || !cache.models) return null;
|
|
16
|
+
const age = Date.now() - cache.timestamp;
|
|
17
|
+
if (age > CACHE_TTL_MS) return null; // expired
|
|
18
|
+
return cache.models;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function saveCache(models) {
|
|
25
|
+
try {
|
|
26
|
+
await fse.ensureDir(CACHE_DIR);
|
|
27
|
+
await fse.writeJson(CACHE_FILE, { timestamp: Date.now(), models });
|
|
28
|
+
} catch {
|
|
29
|
+
// cache write failure is non-fatal
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function fetchModels() {
|
|
34
|
+
// 1. Try cache first (fresh)
|
|
35
|
+
const cached = await loadCache();
|
|
36
|
+
if (cached) return { models: cached, source: 'cache' };
|
|
37
|
+
|
|
38
|
+
// 2. Try network
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(MODELS_URL, { signal: AbortSignal.timeout(8000) });
|
|
41
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
42
|
+
const data = await res.json();
|
|
43
|
+
const models = parseModels(data);
|
|
44
|
+
await saveCache(models);
|
|
45
|
+
return { models, source: 'network' };
|
|
46
|
+
} catch {
|
|
47
|
+
// 3. Network failed, fall back to stale cache if available
|
|
48
|
+
try {
|
|
49
|
+
if (await fse.pathExists(CACHE_FILE)) {
|
|
50
|
+
const cache = await fse.readJson(CACHE_FILE);
|
|
51
|
+
if (cache.models?.length) return { models: cache.models, source: 'stale-cache' };
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore
|
|
55
|
+
}
|
|
56
|
+
return { models: null, source: 'unavailable' };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Providers considered "canonical" for reference pricing, in priority order.
|
|
2
|
+
// When a model's own provider has no cost (e.g. github-copilot shows $0),
|
|
3
|
+
// we look up the same model name in these providers and attach canonicalCost.
|
|
4
|
+
export const CANONICAL_PROVIDERS = ['anthropic', 'openai', 'google', 'mistral', 'meta', 'cohere'];
|
|
5
|
+
|
|
6
|
+
export function parseModels(data) {
|
|
7
|
+
// Build name → canonical cost lookup from authoritative providers first
|
|
8
|
+
const canonicalCostByName = new Map();
|
|
9
|
+
for (const providerId of CANONICAL_PROVIDERS) {
|
|
10
|
+
const provider = data[providerId];
|
|
11
|
+
if (!provider?.models) continue;
|
|
12
|
+
for (const model of Object.values(provider.models)) {
|
|
13
|
+
if (!model.tool_call) continue;
|
|
14
|
+
const name = model.name;
|
|
15
|
+
if (name && model.cost?.input !== undefined && !canonicalCostByName.has(name)) {
|
|
16
|
+
canonicalCostByName.set(name, model.cost.input);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const models = [];
|
|
22
|
+
for (const [providerId, provider] of Object.entries(data)) {
|
|
23
|
+
if (!provider.models) continue;
|
|
24
|
+
for (const [modelId, model] of Object.entries(provider.models)) {
|
|
25
|
+
if (!model.tool_call) continue;
|
|
26
|
+
const name = model.name || modelId;
|
|
27
|
+
const cost = model.cost?.input;
|
|
28
|
+
const canonicalCost = canonicalCostByName.get(name);
|
|
29
|
+
models.push({
|
|
30
|
+
id: `${providerId}/${modelId}`,
|
|
31
|
+
name,
|
|
32
|
+
cost,
|
|
33
|
+
// canonicalCost: cost from the authoritative provider for this model name.
|
|
34
|
+
// Defined when cost !== canonicalCost (different provider, reseller, or $0 subscription).
|
|
35
|
+
canonicalCost: canonicalCost !== undefined && canonicalCost !== cost ? canonicalCost : undefined,
|
|
36
|
+
context: model.limit?.context,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
models.sort((a, b) => (a.cost ?? Infinity) - (b.cost ?? Infinity));
|
|
41
|
+
return models;
|
|
42
|
+
}
|