@uzukko/agentpm 0.1.2 → 0.2.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.
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Manifest cache management with ETag, atomic writes, and fallback chain.
3
+ */
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ renameSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { homedir } from 'node:os'
13
+ import chalk from 'chalk'
14
+ import { validateManifest, satisfiesVersion } from './manifest-validator.js'
15
+ import { BUILTIN_MANIFEST } from './builtin-manifest.js'
16
+ import type { Manifest } from './manifest-validator.js'
17
+
18
+ const CACHE_DIR = join(homedir(), '.agentpm')
19
+ const MANIFEST_PATH = join(CACHE_DIR, 'manifest.json')
20
+ const PREV_PATH = join(CACHE_DIR, 'manifest.prev.json')
21
+ const META_PATH = join(CACHE_DIR, 'manifest.meta.json')
22
+ const TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
23
+
24
+ interface CacheMeta {
25
+ fetchedAt: string
26
+ version: string
27
+ etag?: string
28
+ }
29
+
30
+ // ── Meta helpers ──
31
+
32
+ function readMeta(): CacheMeta | null {
33
+ try {
34
+ if (!existsSync(META_PATH)) return null
35
+ return JSON.parse(readFileSync(META_PATH, 'utf-8'))
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ function saveMeta(meta: CacheMeta): void {
42
+ mkdirSync(CACHE_DIR, { recursive: true, mode: 0o700 })
43
+ const tmpPath = `${META_PATH}.${process.pid}.tmp`
44
+ writeFileSync(tmpPath, JSON.stringify(meta), { mode: 0o600 })
45
+ renameSync(tmpPath, META_PATH)
46
+ }
47
+
48
+ function isFresh(meta: CacheMeta): boolean {
49
+ const fetchedAt = new Date(meta.fetchedAt).getTime()
50
+ return Date.now() - fetchedAt < TTL_MS
51
+ }
52
+
53
+ // ── Cache read/write ──
54
+
55
+ function readAndValidateCache(path: string): Manifest | null {
56
+ try {
57
+ if (!existsSync(path)) return null
58
+ const raw = readFileSync(path, 'utf-8')
59
+ const parsed = JSON.parse(raw)
60
+ return validateManifest(parsed)
61
+ } catch {
62
+ return null // corrupted or validation failed
63
+ }
64
+ }
65
+
66
+ function atomicSaveCache(manifest: Manifest): void {
67
+ mkdirSync(CACHE_DIR, { recursive: true, mode: 0o700 })
68
+ const tmpPath = `${MANIFEST_PATH}.${process.pid}.tmp`
69
+ writeFileSync(tmpPath, JSON.stringify(manifest, null, 2), { mode: 0o600 })
70
+ // Backup current → prev
71
+ try {
72
+ renameSync(MANIFEST_PATH, PREV_PATH)
73
+ } catch {
74
+ /* first run — no existing cache */
75
+ }
76
+ // Atomic swap
77
+ renameSync(tmpPath, MANIFEST_PATH)
78
+ }
79
+
80
+ // ── Network fetch ──
81
+
82
+ async function fetchFromServer(
83
+ apiUrl: string,
84
+ apiKey?: string,
85
+ etag?: string,
86
+ ): Promise<{ manifest?: unknown; etag?: string; notModified: boolean }> {
87
+ const headers: Record<string, string> = { Accept: 'application/json' }
88
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
89
+ if (etag) headers['If-None-Match'] = etag
90
+
91
+ const url = `${apiUrl.replace(/\/$/, '')}/api/cli/manifest`
92
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) })
93
+
94
+ if (res.status === 304) {
95
+ return { etag, notModified: true }
96
+ }
97
+ if (!res.ok) {
98
+ throw new Error(`HTTP ${res.status}`)
99
+ }
100
+
101
+ const manifest = await res.json()
102
+ const newEtag = res.headers.get('etag') || undefined
103
+ return { manifest, etag: newEtag, notModified: false }
104
+ }
105
+
106
+ // ── Public API ──
107
+
108
+ /**
109
+ * Load manifest with 5-level fallback chain:
110
+ * 1. Fresh cache → 2. Server fetch → 3. Expired cache → 4. Prev backup → 5. Builtin
111
+ */
112
+ export async function loadManifest(
113
+ apiUrl: string,
114
+ apiKey?: string,
115
+ cliVersion?: string,
116
+ ): Promise<Manifest> {
117
+ const meta = readMeta()
118
+
119
+ // 1. Fresh cache
120
+ if (meta && isFresh(meta)) {
121
+ const cached = readAndValidateCache(MANIFEST_PATH)
122
+ if (cached) {
123
+ return applyVersionCheck(cached, cliVersion)
124
+ }
125
+ }
126
+
127
+ // 2. Server fetch (with ETag for efficiency)
128
+ try {
129
+ const result = await fetchFromServer(apiUrl, apiKey, meta?.etag)
130
+
131
+ if (result.notModified && meta) {
132
+ // 304: Just refresh TTL
133
+ saveMeta({ ...meta, fetchedAt: new Date().toISOString() })
134
+ const cached = readAndValidateCache(MANIFEST_PATH)
135
+ if (cached) return applyVersionCheck(cached, cliVersion)
136
+ }
137
+
138
+ if (result.manifest) {
139
+ const validated = validateManifest(result.manifest)
140
+ atomicSaveCache(validated)
141
+ saveMeta({
142
+ fetchedAt: new Date().toISOString(),
143
+ version: validated.version,
144
+ etag: result.etag,
145
+ })
146
+ return applyVersionCheck(validated, cliVersion)
147
+ }
148
+ } catch {
149
+ // Network error — fall through to cached fallbacks
150
+ }
151
+
152
+ // 3. Expired cache
153
+ const expired = readAndValidateCache(MANIFEST_PATH)
154
+ if (expired) {
155
+ console.error(chalk.yellow('Warning: Using cached manifest (server unreachable)'))
156
+ return applyVersionCheck(expired, cliVersion)
157
+ }
158
+
159
+ // 4. Previous backup
160
+ const prev = readAndValidateCache(PREV_PATH)
161
+ if (prev) {
162
+ console.error(chalk.yellow('Warning: Using previous manifest (cache corrupted)'))
163
+ return applyVersionCheck(prev, cliVersion)
164
+ }
165
+
166
+ // 5. Builtin
167
+ console.error(chalk.yellow('Warning: Using builtin commands (no manifest available)'))
168
+ return BUILTIN_MANIFEST
169
+ }
170
+
171
+ /**
172
+ * Force-fetch latest manifest (for `agentpm update`)
173
+ */
174
+ export async function forceUpdate(
175
+ apiUrl: string,
176
+ apiKey?: string,
177
+ ): Promise<Manifest> {
178
+ const result = await fetchFromServer(apiUrl, apiKey)
179
+ if (!result.manifest) {
180
+ throw new Error('Server returned no manifest')
181
+ }
182
+ const validated = validateManifest(result.manifest)
183
+ atomicSaveCache(validated)
184
+ saveMeta({
185
+ fetchedAt: new Date().toISOString(),
186
+ version: validated.version,
187
+ etag: result.etag,
188
+ })
189
+ return validated
190
+ }
191
+
192
+ /**
193
+ * Version compatibility check.
194
+ * CLI < minCliVersion → always use builtin (incompatible manifest)
195
+ */
196
+ function applyVersionCheck(manifest: Manifest, cliVersion?: string): Manifest {
197
+ if (!cliVersion) return manifest
198
+ if (!satisfiesVersion(cliVersion, manifest.minCliVersion)) {
199
+ console.error(
200
+ chalk.yellow(
201
+ `Warning: CLI v${cliVersion} は古いです。` +
202
+ `npm update -g @uzukko/agentpm でアップデートしてください`,
203
+ ),
204
+ )
205
+ return BUILTIN_MANIFEST
206
+ }
207
+ return manifest
208
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Manifest schema validation + checksum verification
3
+ */
4
+ import { createHash } from 'node:crypto'
5
+
6
+ export interface ManifestOption {
7
+ flags: string
8
+ description?: string
9
+ param: string
10
+ required?: boolean
11
+ default?: string
12
+ type?: 'int' | 'float' | 'bool' | 'json' | 'string[]' | 'negatable'
13
+ choices?: string[]
14
+ resolve?: 'spaceId'
15
+ dependsOn?: string
16
+ conflictsWith?: string
17
+ }
18
+
19
+ export interface ManifestSubcommand {
20
+ name: string
21
+ description: string
22
+ aliases?: string[]
23
+ tool: string
24
+ examples?: string[]
25
+ deprecated?: boolean
26
+ hidden?: boolean
27
+ stdinMode?: boolean
28
+ options: ManifestOption[]
29
+ }
30
+
31
+ export interface ManifestCommand {
32
+ name: string
33
+ description: string
34
+ aliases?: string[]
35
+ tool?: string
36
+ options?: ManifestOption[]
37
+ subcommands?: ManifestSubcommand[]
38
+ }
39
+
40
+ export interface Manifest {
41
+ version: string
42
+ minCliVersion: string
43
+ generatedAt: string
44
+ checksum: string
45
+ commands: ManifestCommand[]
46
+ }
47
+
48
+ // Validation regex patterns
49
+ const NAME_RE = /^[a-z][a-z0-9-]*$/
50
+ const PARAM_RE = /^[a-zA-Z][a-zA-Z0-9]*$/
51
+ const TOOL_RE = /^[a-z][a-z_]*$/
52
+ const FLAGS_RE = /^(-[a-zA-Z],\s)?--[a-z][a-z0-9-]*(\s<[^>]+>)?$/
53
+
54
+ /** Strip ANSI escape sequences and control characters */
55
+ export function sanitize(str: string): string {
56
+ // eslint-disable-next-line no-control-regex
57
+ return str.replace(/[\x00-\x1f\x7f]|\x1b\[[0-9;]*[a-zA-Z]/g, '')
58
+ }
59
+
60
+ class ManifestValidationError extends Error {
61
+ constructor(message: string) {
62
+ super(message)
63
+ this.name = 'ManifestValidationError'
64
+ }
65
+ }
66
+
67
+ function validateOption(opt: ManifestOption, path: string): void {
68
+ if (!opt.flags || typeof opt.flags !== 'string') {
69
+ throw new ManifestValidationError(`${path}: missing flags`)
70
+ }
71
+ // Allow --no-xxx negatable flags
72
+ const flagsToCheck = opt.type === 'negatable' ? opt.flags.replace('--no-', '--') : opt.flags
73
+ if (!FLAGS_RE.test(flagsToCheck)) {
74
+ throw new ManifestValidationError(`${path}: invalid flags format: ${opt.flags}`)
75
+ }
76
+ if (!opt.param || !PARAM_RE.test(opt.param)) {
77
+ throw new ManifestValidationError(`${path}: invalid param: ${opt.param}`)
78
+ }
79
+ if (opt.type && !['int', 'float', 'bool', 'json', 'string[]', 'negatable'].includes(opt.type)) {
80
+ throw new ManifestValidationError(`${path}: invalid type: ${opt.type}`)
81
+ }
82
+ }
83
+
84
+ function validateSubcommand(sub: ManifestSubcommand, path: string): void {
85
+ if (!sub.name || !NAME_RE.test(sub.name)) {
86
+ throw new ManifestValidationError(`${path}: invalid name: ${sub.name}`)
87
+ }
88
+ if (!sub.tool || !TOOL_RE.test(sub.tool)) {
89
+ throw new ManifestValidationError(`${path}: invalid tool: ${sub.tool}`)
90
+ }
91
+ if (!sub.description || typeof sub.description !== 'string') {
92
+ throw new ManifestValidationError(`${path}: missing description`)
93
+ }
94
+ if (!Array.isArray(sub.options)) {
95
+ throw new ManifestValidationError(`${path}: options must be an array`)
96
+ }
97
+ for (let i = 0; i < sub.options.length; i++) {
98
+ validateOption(sub.options[i], `${path}.options[${i}]`)
99
+ }
100
+ }
101
+
102
+ function validateCommand(cmd: ManifestCommand, path: string): void {
103
+ if (!cmd.name || !NAME_RE.test(cmd.name)) {
104
+ throw new ManifestValidationError(`${path}: invalid name: ${cmd.name}`)
105
+ }
106
+ if (!cmd.description || typeof cmd.description !== 'string') {
107
+ throw new ManifestValidationError(`${path}: missing description`)
108
+ }
109
+
110
+ // Top-level command with direct tool (e.g., dashboard)
111
+ if (cmd.tool) {
112
+ if (!TOOL_RE.test(cmd.tool)) {
113
+ throw new ManifestValidationError(`${path}: invalid tool: ${cmd.tool}`)
114
+ }
115
+ if (cmd.options) {
116
+ for (let i = 0; i < cmd.options.length; i++) {
117
+ validateOption(cmd.options[i], `${path}.options[${i}]`)
118
+ }
119
+ }
120
+ }
121
+
122
+ // Command with subcommands
123
+ if (cmd.subcommands) {
124
+ if (!Array.isArray(cmd.subcommands)) {
125
+ throw new ManifestValidationError(`${path}: subcommands must be an array`)
126
+ }
127
+ for (let i = 0; i < cmd.subcommands.length; i++) {
128
+ validateSubcommand(cmd.subcommands[i], `${path}.subcommands[${i}]`)
129
+ }
130
+ }
131
+
132
+ if (!cmd.tool && !cmd.subcommands) {
133
+ throw new ManifestValidationError(`${path}: must have either tool or subcommands`)
134
+ }
135
+ }
136
+
137
+ function verifyChecksum(manifest: Manifest): boolean {
138
+ const expected = manifest.checksum
139
+ if (!expected || !expected.startsWith('sha256:')) return false
140
+ const computed = createHash('sha256').update(JSON.stringify(manifest.commands)).digest('hex')
141
+ return expected === `sha256:${computed}`
142
+ }
143
+
144
+ /**
145
+ * Validate a parsed manifest object.
146
+ * Returns the typed manifest or throws ManifestValidationError.
147
+ */
148
+ export function validateManifest(raw: unknown): Manifest {
149
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
150
+ throw new ManifestValidationError('Manifest must be a JSON object')
151
+ }
152
+
153
+ const m = raw as Record<string, unknown>
154
+
155
+ if (typeof m.version !== 'string') {
156
+ throw new ManifestValidationError('Missing version')
157
+ }
158
+ if (typeof m.minCliVersion !== 'string') {
159
+ throw new ManifestValidationError('Missing minCliVersion')
160
+ }
161
+ if (!Array.isArray(m.commands)) {
162
+ throw new ManifestValidationError('Missing commands array')
163
+ }
164
+
165
+ const manifest = raw as Manifest
166
+
167
+ // Checksum verification (corruption detection)
168
+ // Empty checksum is allowed for builtin manifest only (version '0.0.0-builtin')
169
+ if (manifest.checksum) {
170
+ if (!verifyChecksum(manifest)) {
171
+ throw new ManifestValidationError('Checksum mismatch — manifest may be corrupted')
172
+ }
173
+ } else if (manifest.version !== '0.0.0-builtin') {
174
+ throw new ManifestValidationError('Missing checksum — manifest integrity cannot be verified')
175
+ }
176
+
177
+ // Validate each command
178
+ for (let i = 0; i < manifest.commands.length; i++) {
179
+ validateCommand(manifest.commands[i], `commands[${i}]`)
180
+ }
181
+
182
+ // Sanitize display strings
183
+ for (const cmd of manifest.commands) {
184
+ cmd.description = sanitize(cmd.description)
185
+ if (cmd.subcommands) {
186
+ for (const sub of cmd.subcommands) {
187
+ sub.description = sanitize(sub.description)
188
+ if (sub.examples) {
189
+ sub.examples = sub.examples.map(sanitize)
190
+ }
191
+ for (const opt of sub.options) {
192
+ if (opt.description) opt.description = sanitize(opt.description)
193
+ }
194
+ }
195
+ }
196
+ if (cmd.options) {
197
+ for (const opt of cmd.options) {
198
+ if (opt.description) opt.description = sanitize(opt.description)
199
+ }
200
+ }
201
+ }
202
+
203
+ return manifest
204
+ }
205
+
206
+ /**
207
+ * Compare semver strings: returns true if current >= required
208
+ */
209
+ export function satisfiesVersion(current: string, required: string): boolean {
210
+ const parse = (v: string) => v.split('.').map(Number)
211
+ const [cMaj, cMin = 0, cPat = 0] = parse(current)
212
+ const [rMaj, rMin = 0, rPat = 0] = parse(required)
213
+ if (cMaj !== rMaj) return cMaj > rMaj
214
+ if (cMin !== rMin) return cMin > rMin
215
+ return cPat >= rPat
216
+ }
package/README.md DELETED
@@ -1,161 +0,0 @@
1
- # AgentPM CLI
2
-
3
- Command-line client for [AgentPM](https://agentpm.app) — AI-first project management.
4
-
5
- [日本語はこちら](#日本語)
6
-
7
- ## Installation
8
-
9
- ```bash
10
- npm install -g @uzukko/agentpm
11
- ```
12
-
13
- ## Setup
14
-
15
- 1. Create an account at [AgentPM](https://agentpm.app)
16
- 2. Go to Settings → API Keys and generate a key
17
- 3. Login via CLI:
18
-
19
- ```bash
20
- agentpm login
21
- ```
22
-
23
- ## Usage
24
-
25
- ```bash
26
- # List projects
27
- agentpm space list
28
-
29
- # List tasks
30
- agentpm task list --space-id <SPACE_ID>
31
-
32
- # Create a task
33
- agentpm task create --space-id <SPACE_ID> --title "Task title"
34
-
35
- # Get task details
36
- agentpm task get --id <TASK_ID>
37
-
38
- # Update task
39
- agentpm task update --id <TASK_ID> --status in_progress
40
-
41
- # Toss ball (change who needs to act next)
42
- agentpm ball toss --task-id <TASK_ID> --side client --reason "Please review"
43
-
44
- # Milestones
45
- agentpm milestone list --space-id <SPACE_ID>
46
-
47
- # Meetings
48
- agentpm meeting list --space-id <SPACE_ID>
49
-
50
- # Wiki
51
- agentpm wiki list --space-id <SPACE_ID>
52
- ```
53
-
54
- Run `agentpm --help` for the full list of commands.
55
-
56
- ## Configuration
57
-
58
- Saved in `~/.taskapprc.json`:
59
-
60
- ```json
61
- {
62
- "apiKey": "tsk_...",
63
- "apiUrl": "https://agentpm.app",
64
- "defaultSpaceId": "optional"
65
- }
66
- ```
67
-
68
- Set `defaultSpaceId` to skip `--space-id` on every command.
69
-
70
- ## Environment Variables
71
-
72
- Override config file settings:
73
-
74
- | Variable | Description |
75
- |----------|-------------|
76
- | `TASKAPP_API_KEY` | API Key |
77
- | `TASKAPP_API_URL` | API URL (default: https://agentpm.app) |
78
- | `TASKAPP_SPACE_ID` | Default Space ID |
79
-
80
- ---
81
-
82
- ## 日本語
83
-
84
- AI ファーストのプロジェクト管理ツール [AgentPM](https://agentpm.app) のコマンドラインクライアントです。
85
-
86
- ### インストール
87
-
88
- ```bash
89
- npm install -g @uzukko/agentpm
90
- ```
91
-
92
- ### セットアップ
93
-
94
- 1. [AgentPM](https://agentpm.app) でアカウント作成
95
- 2. 設定 → APIキー管理 から API Key を発行
96
- 3. CLI にログイン:
97
-
98
- ```bash
99
- agentpm login
100
- ```
101
-
102
- ### 使い方
103
-
104
- ```bash
105
- # プロジェクト一覧
106
- agentpm space list
107
-
108
- # タスク一覧
109
- agentpm task list --space-id <SPACE_ID>
110
-
111
- # タスク作成
112
- agentpm task create --space-id <SPACE_ID> --title "タスク名"
113
-
114
- # タスク詳細
115
- agentpm task get --id <TASK_ID>
116
-
117
- # タスク更新
118
- agentpm task update --id <TASK_ID> --status in_progress
119
-
120
- # ボール変更(次に誰がアクションすべきか)
121
- agentpm ball toss --task-id <TASK_ID> --side client --reason "確認お願いします"
122
-
123
- # マイルストーン一覧
124
- agentpm milestone list --space-id <SPACE_ID>
125
-
126
- # ミーティング一覧
127
- agentpm meeting list --space-id <SPACE_ID>
128
-
129
- # Wiki ページ一覧
130
- agentpm wiki list --space-id <SPACE_ID>
131
- ```
132
-
133
- 全コマンド一覧は `agentpm --help` で確認できます。
134
-
135
- ### 設定ファイル
136
-
137
- `~/.taskapprc.json` に保存されます:
138
-
139
- ```json
140
- {
141
- "apiKey": "tsk_...",
142
- "apiUrl": "https://agentpm.app",
143
- "defaultSpaceId": "省略可"
144
- }
145
- ```
146
-
147
- `defaultSpaceId` を設定すると `--space-id` の指定を省略できます。
148
-
149
- ### 環境変数
150
-
151
- 設定ファイルより優先されます:
152
-
153
- | 変数 | 説明 |
154
- |------|------|
155
- | `TASKAPP_API_KEY` | API Key |
156
- | `TASKAPP_API_URL` | API URL (デフォルト: https://agentpm.app) |
157
- | `TASKAPP_SPACE_ID` | デフォルト Space ID |
158
-
159
- ## License
160
-
161
- MIT
@@ -1,4 +0,0 @@
1
- export declare const defaults: {
2
- supabaseUrl: string;
3
- supabaseServiceKey: string;
4
- };
package/dist/defaults.js DELETED
@@ -1,8 +0,0 @@
1
- // Built-in defaults for the CLI.
2
- // For npm publish builds, these are injected at build time via scripts/inject-defaults.sh.
3
- // Developers can override any value via env vars or ~/.taskapprc.json.
4
- export const defaults = {
5
- supabaseUrl: process.env.TASKAPP_BUILTIN_SUPABASE_URL || '',
6
- supabaseServiceKey: process.env.TASKAPP_BUILTIN_SUPABASE_SERVICE_KEY || '',
7
- };
8
- //# sourceMappingURL=defaults.js.map