@skillrecordings/cli 0.1.0 → 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.
- package/bin/skill.mjs +27 -0
- package/dist/chunk-2NCCVTEE.js +22342 -0
- package/dist/chunk-2NCCVTEE.js.map +1 -0
- package/dist/chunk-3E3GYSZR.js +7071 -0
- package/dist/chunk-3E3GYSZR.js.map +1 -0
- package/dist/chunk-F4EM72IH.js +86 -0
- package/dist/chunk-F4EM72IH.js.map +1 -0
- package/dist/chunk-FGP7KUQW.js +432 -0
- package/dist/chunk-FGP7KUQW.js.map +1 -0
- package/dist/chunk-H3D6VCME.js +55 -0
- package/dist/chunk-H3D6VCME.js.map +1 -0
- package/dist/chunk-HK3PEWFD.js +208 -0
- package/dist/chunk-HK3PEWFD.js.map +1 -0
- package/dist/chunk-KEV3QKXP.js +4495 -0
- package/dist/chunk-KEV3QKXP.js.map +1 -0
- package/dist/chunk-MG37YDAK.js +882 -0
- package/dist/chunk-MG37YDAK.js.map +1 -0
- package/dist/chunk-MLNDSBZ4.js +482 -0
- package/dist/chunk-MLNDSBZ4.js.map +1 -0
- package/dist/chunk-N2WIV2JV.js +22 -0
- package/dist/chunk-N2WIV2JV.js.map +1 -0
- package/dist/chunk-PWWRCN5W.js +2067 -0
- package/dist/chunk-PWWRCN5W.js.map +1 -0
- package/dist/chunk-SKHBM3XP.js +7746 -0
- package/dist/chunk-SKHBM3XP.js.map +1 -0
- package/dist/chunk-WFANXVQG.js +64 -0
- package/dist/chunk-WFANXVQG.js.map +1 -0
- package/dist/chunk-WYKL32C3.js +275 -0
- package/dist/chunk-WYKL32C3.js.map +1 -0
- package/dist/chunk-ZNF7XD2S.js +134 -0
- package/dist/chunk-ZNF7XD2S.js.map +1 -0
- package/dist/config-AUAIYDSI.js +20 -0
- package/dist/config-AUAIYDSI.js.map +1 -0
- package/dist/fileFromPath-XN7LXIBI.js +134 -0
- package/dist/fileFromPath-XN7LXIBI.js.map +1 -0
- package/dist/getMachineId-bsd-KW2E7VK3.js +42 -0
- package/dist/getMachineId-bsd-KW2E7VK3.js.map +1 -0
- package/dist/getMachineId-darwin-ROXJUJX5.js +42 -0
- package/dist/getMachineId-darwin-ROXJUJX5.js.map +1 -0
- package/dist/getMachineId-linux-KVZEHQSU.js +34 -0
- package/dist/getMachineId-linux-KVZEHQSU.js.map +1 -0
- package/dist/getMachineId-unsupported-PPRILPPA.js +25 -0
- package/dist/getMachineId-unsupported-PPRILPPA.js.map +1 -0
- package/dist/getMachineId-win-IIF36LEJ.js +44 -0
- package/dist/getMachineId-win-IIF36LEJ.js.map +1 -0
- package/dist/index.js +112703 -0
- package/dist/index.js.map +1 -0
- package/dist/lib-R6DEEJCP.js +7623 -0
- package/dist/lib-R6DEEJCP.js.map +1 -0
- package/dist/pipeline-IAVVAKTU.js +120 -0
- package/dist/pipeline-IAVVAKTU.js.map +1 -0
- package/dist/query-NTP5NVXN.js +25 -0
- package/dist/query-NTP5NVXN.js.map +1 -0
- package/dist/routing-BAEPFB7V.js +390 -0
- package/dist/routing-BAEPFB7V.js.map +1 -0
- package/dist/stripe-lookup-charge-EPRUMZDL.js +56 -0
- package/dist/stripe-lookup-charge-EPRUMZDL.js.map +1 -0
- package/dist/stripe-payment-history-SJPKA63N.js +67 -0
- package/dist/stripe-payment-history-SJPKA63N.js.map +1 -0
- package/dist/stripe-subscription-status-L4Z65GB3.js +58 -0
- package/dist/stripe-subscription-status-L4Z65GB3.js.map +1 -0
- package/dist/stripe-verify-refund-FZDKCIUQ.js +54 -0
- package/dist/stripe-verify-refund-FZDKCIUQ.js.map +1 -0
- package/dist/support-memory-WSG7SDKG.js +10 -0
- package/dist/support-memory-WSG7SDKG.js.map +1 -0
- package/package.json +10 -7
- package/.env.encrypted +0 -0
- package/CHANGELOG.md +0 -35
- package/data/tt-archive-dataset.json +0 -1
- package/data/validate-test-dataset.json +0 -97
- package/docs/CLI-AUTH.md +0 -504
- package/preload.ts +0 -18
- package/src/__tests__/init.test.ts +0 -74
- package/src/alignment-test.ts +0 -64
- package/src/check-apps.ts +0 -16
- package/src/commands/auth/decrypt.ts +0 -123
- package/src/commands/auth/encrypt.ts +0 -81
- package/src/commands/auth/index.ts +0 -50
- package/src/commands/auth/keygen.ts +0 -41
- package/src/commands/auth/status.ts +0 -164
- package/src/commands/axiom/forensic.ts +0 -868
- package/src/commands/axiom/index.ts +0 -697
- package/src/commands/build-dataset.ts +0 -311
- package/src/commands/db-status.ts +0 -47
- package/src/commands/deploys.ts +0 -219
- package/src/commands/eval-local/compare.ts +0 -171
- package/src/commands/eval-local/health.ts +0 -212
- package/src/commands/eval-local/index.ts +0 -76
- package/src/commands/eval-local/real-tools.ts +0 -416
- package/src/commands/eval-local/run.ts +0 -1168
- package/src/commands/eval-local/score-production.ts +0 -256
- package/src/commands/eval-local/seed.ts +0 -276
- package/src/commands/eval-pipeline/index.ts +0 -53
- package/src/commands/eval-pipeline/real-tools.ts +0 -492
- package/src/commands/eval-pipeline/run.ts +0 -1316
- package/src/commands/eval-pipeline/seed.ts +0 -395
- package/src/commands/eval-prompt.ts +0 -496
- package/src/commands/eval.test.ts +0 -253
- package/src/commands/eval.ts +0 -108
- package/src/commands/faq-classify.ts +0 -460
- package/src/commands/faq-cluster.ts +0 -135
- package/src/commands/faq-extract.ts +0 -249
- package/src/commands/faq-mine.ts +0 -432
- package/src/commands/faq-review.ts +0 -426
- package/src/commands/front/index.ts +0 -351
- package/src/commands/front/pull-conversations.ts +0 -275
- package/src/commands/front/tags.ts +0 -825
- package/src/commands/front-cache.ts +0 -1277
- package/src/commands/front-stats.ts +0 -75
- package/src/commands/health.test.ts +0 -82
- package/src/commands/health.ts +0 -362
- package/src/commands/init.test.ts +0 -89
- package/src/commands/init.ts +0 -106
- package/src/commands/inngest/client.ts +0 -294
- package/src/commands/inngest/events.ts +0 -296
- package/src/commands/inngest/investigate.ts +0 -382
- package/src/commands/inngest/runs.ts +0 -149
- package/src/commands/inngest/signal.ts +0 -143
- package/src/commands/kb-sync.ts +0 -498
- package/src/commands/memory/find.ts +0 -135
- package/src/commands/memory/get.ts +0 -87
- package/src/commands/memory/index.ts +0 -97
- package/src/commands/memory/stats.ts +0 -163
- package/src/commands/memory/store.ts +0 -49
- package/src/commands/memory/vote.ts +0 -159
- package/src/commands/pipeline.ts +0 -127
- package/src/commands/responses.ts +0 -856
- package/src/commands/tools.ts +0 -293
- package/src/commands/wizard.ts +0 -319
- package/src/index.ts +0 -172
- package/src/lib/crypto.ts +0 -56
- package/src/lib/env-loader.ts +0 -206
- package/src/lib/onepassword.ts +0 -137
- package/src/test-agent-local.ts +0 -115
- package/tsconfig.json +0 -11
- package/vitest.config.ts +0 -10
|
@@ -1,825 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Front CLI tag management commands
|
|
3
|
-
*
|
|
4
|
-
* Provides commands for:
|
|
5
|
-
* - Listing tags with conversation counts
|
|
6
|
-
* - Filtering unused tags
|
|
7
|
-
* - Deleting tags
|
|
8
|
-
* - Renaming tags
|
|
9
|
-
* - Cleanup (duplicates, case variants, obsolete tags)
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { confirm } from '@inquirer/prompts'
|
|
13
|
-
import { createInstrumentedFrontClient } from '@skillrecordings/core/front/instrumented-client'
|
|
14
|
-
import {
|
|
15
|
-
type TagWithConversationCount,
|
|
16
|
-
findCaseVariants,
|
|
17
|
-
findExactDuplicates,
|
|
18
|
-
} from '@skillrecordings/core/tags/audit'
|
|
19
|
-
import { DEFAULT_CATEGORY_TAG_MAPPING } from '@skillrecordings/core/tags/registry'
|
|
20
|
-
import type { Command } from 'commander'
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Get Front SDK client from environment
|
|
24
|
-
*/
|
|
25
|
-
function getFrontSdkClient() {
|
|
26
|
-
const apiToken = process.env.FRONT_API_TOKEN
|
|
27
|
-
if (!apiToken) {
|
|
28
|
-
throw new Error('FRONT_API_TOKEN environment variable is required')
|
|
29
|
-
}
|
|
30
|
-
return createInstrumentedFrontClient({ apiToken })
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Get Front API token
|
|
35
|
-
*/
|
|
36
|
-
function getFrontApiToken() {
|
|
37
|
-
const apiToken = process.env.FRONT_API_TOKEN
|
|
38
|
-
if (!apiToken) {
|
|
39
|
-
throw new Error('FRONT_API_TOKEN environment variable is required')
|
|
40
|
-
}
|
|
41
|
-
return apiToken
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Raw fetch for tags - bypasses SDK validation which chokes on Front's messy data
|
|
46
|
-
*/
|
|
47
|
-
async function fetchTagsRaw(): Promise<
|
|
48
|
-
Array<{
|
|
49
|
-
id: string
|
|
50
|
-
name: string
|
|
51
|
-
highlight?: string | null
|
|
52
|
-
is_private: boolean
|
|
53
|
-
description?: string | null
|
|
54
|
-
}>
|
|
55
|
-
> {
|
|
56
|
-
const apiToken = getFrontApiToken()
|
|
57
|
-
const allTags: Array<{
|
|
58
|
-
id: string
|
|
59
|
-
name: string
|
|
60
|
-
highlight?: string | null
|
|
61
|
-
is_private: boolean
|
|
62
|
-
description?: string | null
|
|
63
|
-
}> = []
|
|
64
|
-
|
|
65
|
-
let nextUrl: string | null = 'https://api2.frontapp.com/tags'
|
|
66
|
-
const front = createInstrumentedFrontClient({ apiToken })
|
|
67
|
-
|
|
68
|
-
while (nextUrl) {
|
|
69
|
-
const data = (await front.raw.get(nextUrl)) as {
|
|
70
|
-
_results: Array<{
|
|
71
|
-
id: string
|
|
72
|
-
name: string
|
|
73
|
-
highlight?: string | null
|
|
74
|
-
is_private: boolean
|
|
75
|
-
description?: string | null
|
|
76
|
-
}>
|
|
77
|
-
_pagination?: { next?: string }
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
allTags.push(...data._results)
|
|
81
|
-
nextUrl = data._pagination?.next ?? null
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return allTags
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Raw create tag - bypasses SDK response validation
|
|
89
|
-
*/
|
|
90
|
-
async function createTagRaw(params: {
|
|
91
|
-
name: string
|
|
92
|
-
highlight?: string | null
|
|
93
|
-
description?: string | null
|
|
94
|
-
}): Promise<void> {
|
|
95
|
-
const apiToken = getFrontApiToken()
|
|
96
|
-
const body: Record<string, unknown> = { name: params.name }
|
|
97
|
-
if (params.highlight) body.highlight = params.highlight
|
|
98
|
-
if (params.description) body.description = params.description
|
|
99
|
-
|
|
100
|
-
const front = createInstrumentedFrontClient({ apiToken })
|
|
101
|
-
await front.raw.post('/tags', body)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Truncate string with ellipsis
|
|
106
|
-
*/
|
|
107
|
-
function truncate(str: string, len: number): string {
|
|
108
|
-
if (str.length <= len) return str
|
|
109
|
-
return str.slice(0, len - 3) + '...'
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
interface TagWithCount {
|
|
113
|
-
id: string
|
|
114
|
-
name: string
|
|
115
|
-
highlight: string | null
|
|
116
|
-
is_private: boolean
|
|
117
|
-
description: string | null
|
|
118
|
-
conversation_count: number
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Sleep for a given number of milliseconds
|
|
123
|
-
*/
|
|
124
|
-
function sleep(ms: number): Promise<void> {
|
|
125
|
-
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Get conversation count for a tag
|
|
130
|
-
* Uses the conversations endpoint and checks pagination
|
|
131
|
-
*/
|
|
132
|
-
async function getConversationCount(
|
|
133
|
-
front: ReturnType<typeof createInstrumentedFrontClient>,
|
|
134
|
-
tagId: string
|
|
135
|
-
): Promise<number> {
|
|
136
|
-
try {
|
|
137
|
-
const result = (await front.tags.listConversations(tagId)) as {
|
|
138
|
-
_results?: unknown[]
|
|
139
|
-
_pagination?: { total?: number }
|
|
140
|
-
}
|
|
141
|
-
// Use pagination total if available, otherwise count results
|
|
142
|
-
return result._pagination?.total ?? result._results?.length ?? 0
|
|
143
|
-
} catch {
|
|
144
|
-
return 0
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Fetch conversation counts for tags with rate limiting
|
|
150
|
-
* @param tags - Array of tags to fetch counts for
|
|
151
|
-
* @param front - Front SDK client
|
|
152
|
-
* @param delayMs - Delay between API calls (default 100ms)
|
|
153
|
-
* @param batchSize - Number of concurrent requests (default 5)
|
|
154
|
-
* @param onProgress - Callback for progress updates
|
|
155
|
-
*/
|
|
156
|
-
async function fetchConversationCountsRateLimited<
|
|
157
|
-
T extends { id: string; name: string },
|
|
158
|
-
>(
|
|
159
|
-
tags: T[],
|
|
160
|
-
front: ReturnType<typeof createInstrumentedFrontClient>,
|
|
161
|
-
options: {
|
|
162
|
-
delayMs?: number
|
|
163
|
-
batchSize?: number
|
|
164
|
-
onProgress?: (completed: number, total: number) => void
|
|
165
|
-
} = {}
|
|
166
|
-
): Promise<Map<string, number>> {
|
|
167
|
-
const { delayMs = 100, batchSize = 5, onProgress } = options
|
|
168
|
-
const counts = new Map<string, number>()
|
|
169
|
-
|
|
170
|
-
// Process in batches
|
|
171
|
-
for (let i = 0; i < tags.length; i += batchSize) {
|
|
172
|
-
const batch = tags.slice(i, i + batchSize)
|
|
173
|
-
|
|
174
|
-
// Fetch batch concurrently
|
|
175
|
-
const results = await Promise.all(
|
|
176
|
-
batch.map(async (tag) => {
|
|
177
|
-
const count = await getConversationCount(front, tag.id)
|
|
178
|
-
return { id: tag.id, count }
|
|
179
|
-
})
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
// Store results
|
|
183
|
-
for (const { id, count } of results) {
|
|
184
|
-
counts.set(id, count)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Progress update
|
|
188
|
-
const completed = Math.min(i + batchSize, tags.length)
|
|
189
|
-
onProgress?.(completed, tags.length)
|
|
190
|
-
|
|
191
|
-
// Rate limit delay between batches (not after last batch)
|
|
192
|
-
if (i + batchSize < tags.length) {
|
|
193
|
-
await sleep(delayMs)
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return counts
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Command: skill front tags list
|
|
202
|
-
* List all tags with conversation counts
|
|
203
|
-
*/
|
|
204
|
-
async function listTags(options: {
|
|
205
|
-
json?: boolean
|
|
206
|
-
unused?: boolean
|
|
207
|
-
}): Promise<void> {
|
|
208
|
-
try {
|
|
209
|
-
const front = getFrontSdkClient()
|
|
210
|
-
const tags = await fetchTagsRaw()
|
|
211
|
-
|
|
212
|
-
// Fetch conversation counts for each tag
|
|
213
|
-
const tagsWithCounts: TagWithCount[] = await Promise.all(
|
|
214
|
-
tags.map(async (tag) => {
|
|
215
|
-
const count = await getConversationCount(front, tag.id)
|
|
216
|
-
return {
|
|
217
|
-
id: tag.id,
|
|
218
|
-
name: tag.name,
|
|
219
|
-
highlight: tag.highlight ?? null,
|
|
220
|
-
is_private: tag.is_private,
|
|
221
|
-
description: tag.description ?? null,
|
|
222
|
-
conversation_count: count,
|
|
223
|
-
}
|
|
224
|
-
})
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
// Filter to unused if requested
|
|
228
|
-
const filteredTags = options.unused
|
|
229
|
-
? tagsWithCounts.filter((t) => t.conversation_count === 0)
|
|
230
|
-
: tagsWithCounts
|
|
231
|
-
|
|
232
|
-
if (options.json) {
|
|
233
|
-
console.log(JSON.stringify(filteredTags, null, 2))
|
|
234
|
-
return
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if (filteredTags.length === 0) {
|
|
238
|
-
if (options.unused) {
|
|
239
|
-
console.log('\n✨ No unused tags found!\n')
|
|
240
|
-
} else {
|
|
241
|
-
console.log('\n📭 No tags found.\n')
|
|
242
|
-
}
|
|
243
|
-
return
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const header = options.unused ? '🏷️ Unused Tags' : '🏷️ All Tags'
|
|
247
|
-
console.log(`\n${header} (${filteredTags.length}):`)
|
|
248
|
-
console.log('-'.repeat(80))
|
|
249
|
-
|
|
250
|
-
// Table header
|
|
251
|
-
console.log(
|
|
252
|
-
`${'ID'.padEnd(20)} ${'Name'.padEnd(30)} ${'Color'.padEnd(10)} ${'Convos'.padEnd(8)}`
|
|
253
|
-
)
|
|
254
|
-
console.log('-'.repeat(80))
|
|
255
|
-
|
|
256
|
-
for (const tag of filteredTags) {
|
|
257
|
-
const highlight = tag.highlight || '-'
|
|
258
|
-
const countStr =
|
|
259
|
-
tag.conversation_count === 0 ? '0 ⚠️' : tag.conversation_count.toString()
|
|
260
|
-
|
|
261
|
-
console.log(
|
|
262
|
-
`${truncate(tag.id, 20).padEnd(20)} ${truncate(tag.name, 30).padEnd(30)} ${highlight.padEnd(10)} ${countStr.padEnd(8)}`
|
|
263
|
-
)
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
console.log('')
|
|
267
|
-
|
|
268
|
-
if (!options.unused) {
|
|
269
|
-
const unusedCount = tagsWithCounts.filter(
|
|
270
|
-
(t) => t.conversation_count === 0
|
|
271
|
-
).length
|
|
272
|
-
if (unusedCount > 0) {
|
|
273
|
-
console.log(
|
|
274
|
-
`💡 Found ${unusedCount} unused tag(s). Use --unused to filter.\n`
|
|
275
|
-
)
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
} catch (error) {
|
|
279
|
-
if (options.json) {
|
|
280
|
-
console.error(
|
|
281
|
-
JSON.stringify({
|
|
282
|
-
error: error instanceof Error ? error.message : 'Unknown error',
|
|
283
|
-
})
|
|
284
|
-
)
|
|
285
|
-
} else {
|
|
286
|
-
console.error(
|
|
287
|
-
'Error:',
|
|
288
|
-
error instanceof Error ? error.message : 'Unknown error'
|
|
289
|
-
)
|
|
290
|
-
}
|
|
291
|
-
process.exit(1)
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Command: skill front tags delete <id>
|
|
297
|
-
* Delete a tag by ID
|
|
298
|
-
*/
|
|
299
|
-
async function deleteTag(
|
|
300
|
-
id: string,
|
|
301
|
-
options: { force?: boolean }
|
|
302
|
-
): Promise<void> {
|
|
303
|
-
try {
|
|
304
|
-
const front = getFrontSdkClient()
|
|
305
|
-
|
|
306
|
-
// Fetch tag details first
|
|
307
|
-
const tag = await front.tags.get(id)
|
|
308
|
-
const convCount = await getConversationCount(front, id)
|
|
309
|
-
|
|
310
|
-
if (!options.force) {
|
|
311
|
-
console.log(`\n🏷️ Tag: ${tag.name}`)
|
|
312
|
-
console.log(` ID: ${tag.id}`)
|
|
313
|
-
console.log(` Conversations: ${convCount}`)
|
|
314
|
-
|
|
315
|
-
if (convCount > 0) {
|
|
316
|
-
console.log(
|
|
317
|
-
`\n⚠️ Warning: This tag is used in ${convCount} conversation(s).`
|
|
318
|
-
)
|
|
319
|
-
console.log(
|
|
320
|
-
' Deleting it will remove the tag from those conversations.'
|
|
321
|
-
)
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const confirmed = await confirm({
|
|
325
|
-
message: `Are you sure you want to delete tag "${tag.name}"?`,
|
|
326
|
-
default: false,
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
if (!confirmed) {
|
|
330
|
-
console.log('\n❌ Cancelled.\n')
|
|
331
|
-
return
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
await front.tags.delete(id)
|
|
336
|
-
console.log(`\n✅ Deleted tag "${tag.name}" (${id})\n`)
|
|
337
|
-
} catch (error) {
|
|
338
|
-
console.error(
|
|
339
|
-
'Error:',
|
|
340
|
-
error instanceof Error ? error.message : 'Unknown error'
|
|
341
|
-
)
|
|
342
|
-
process.exit(1)
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Command: skill front tags rename <id> <name>
|
|
348
|
-
* Rename a tag
|
|
349
|
-
*/
|
|
350
|
-
async function renameTag(id: string, newName: string): Promise<void> {
|
|
351
|
-
try {
|
|
352
|
-
const front = getFrontSdkClient()
|
|
353
|
-
|
|
354
|
-
// Fetch current tag details
|
|
355
|
-
const oldTag = await front.tags.get(id)
|
|
356
|
-
const oldName = oldTag.name
|
|
357
|
-
|
|
358
|
-
// Update the tag
|
|
359
|
-
const updatedTag = await front.tags.update(id, { name: newName })
|
|
360
|
-
|
|
361
|
-
console.log(`\n✅ Renamed tag:`)
|
|
362
|
-
console.log(` "${oldName}" → "${updatedTag.name}"`)
|
|
363
|
-
console.log(` ID: ${id}\n`)
|
|
364
|
-
} catch (error) {
|
|
365
|
-
console.error(
|
|
366
|
-
'Error:',
|
|
367
|
-
error instanceof Error ? error.message : 'Unknown error'
|
|
368
|
-
)
|
|
369
|
-
process.exit(1)
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// ============================================================================
|
|
374
|
-
// Cleanup Command
|
|
375
|
-
// ============================================================================
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Patterns for obsolete tags that should be deleted
|
|
379
|
-
*/
|
|
380
|
-
const OBSOLETE_TAG_PATTERNS = [
|
|
381
|
-
/^giftmas$/i,
|
|
382
|
-
/^jan-2022$/i,
|
|
383
|
-
/^feb-2022$/i,
|
|
384
|
-
/^mar-2022$/i,
|
|
385
|
-
/^apr-2022$/i,
|
|
386
|
-
/^may-2022$/i,
|
|
387
|
-
/^jun-2022$/i,
|
|
388
|
-
/^jul-2022$/i,
|
|
389
|
-
/^aug-2022$/i,
|
|
390
|
-
/^sep-2022$/i,
|
|
391
|
-
/^oct-2022$/i,
|
|
392
|
-
/^nov-2022$/i,
|
|
393
|
-
/^dec-2022$/i,
|
|
394
|
-
// Gmail import artifacts (e.g., "INBOX", "STARRED", etc.)
|
|
395
|
-
/^INBOX$/,
|
|
396
|
-
/^STARRED$/,
|
|
397
|
-
/^IMPORTANT$/,
|
|
398
|
-
/^SENT$/,
|
|
399
|
-
/^DRAFT$/,
|
|
400
|
-
/^CATEGORY_/,
|
|
401
|
-
/^UNREAD$/,
|
|
402
|
-
]
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Convert name to canonical lowercase-hyphenated form
|
|
406
|
-
*/
|
|
407
|
-
function toCanonicalForm(name: string): string {
|
|
408
|
-
return name
|
|
409
|
-
.toLowerCase()
|
|
410
|
-
.replace(/[_\s]+/g, '-')
|
|
411
|
-
.replace(/[^a-z0-9-]/g, '')
|
|
412
|
-
.replace(/-+/g, '-')
|
|
413
|
-
.replace(/^-|-$/g, '')
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Check if tag name matches obsolete patterns
|
|
418
|
-
*/
|
|
419
|
-
function isObsoleteTag(name: string): boolean {
|
|
420
|
-
return OBSOLETE_TAG_PATTERNS.some((pattern) => pattern.test(name))
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
interface CleanupPlan {
|
|
424
|
-
duplicatesToDelete: Array<{
|
|
425
|
-
tag: TagWithConversationCount
|
|
426
|
-
keepTag: TagWithConversationCount
|
|
427
|
-
reason: string
|
|
428
|
-
}>
|
|
429
|
-
caseVariantsToRename: Array<{
|
|
430
|
-
tag: TagWithConversationCount
|
|
431
|
-
newName: string
|
|
432
|
-
canonical: TagWithConversationCount
|
|
433
|
-
}>
|
|
434
|
-
obsoleteToDelete: TagWithConversationCount[]
|
|
435
|
-
missingToCreate: Array<{
|
|
436
|
-
name: string
|
|
437
|
-
highlight: string
|
|
438
|
-
description: string
|
|
439
|
-
}>
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* Build cleanup plan from current tags
|
|
444
|
-
*/
|
|
445
|
-
async function buildCleanupPlan(
|
|
446
|
-
front: ReturnType<typeof createInstrumentedFrontClient>,
|
|
447
|
-
tagsWithCounts: TagWithConversationCount[]
|
|
448
|
-
): Promise<CleanupPlan> {
|
|
449
|
-
const plan: CleanupPlan = {
|
|
450
|
-
duplicatesToDelete: [],
|
|
451
|
-
caseVariantsToRename: [],
|
|
452
|
-
obsoleteToDelete: [],
|
|
453
|
-
missingToCreate: [],
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// 1. Find exact duplicates
|
|
457
|
-
const exactDuplicates = findExactDuplicates(tagsWithCounts)
|
|
458
|
-
for (const group of exactDuplicates) {
|
|
459
|
-
// Sort by conversation count descending - keep the one with most conversations
|
|
460
|
-
const sorted = [...group.tags].sort(
|
|
461
|
-
(a, b) => b.conversationCount - a.conversationCount
|
|
462
|
-
)
|
|
463
|
-
const keep = sorted[0]!
|
|
464
|
-
for (const tag of sorted.slice(1)) {
|
|
465
|
-
plan.duplicatesToDelete.push({
|
|
466
|
-
tag,
|
|
467
|
-
keepTag: keep,
|
|
468
|
-
reason: `Exact duplicate of "${keep.name}" (keeping ${keep.conversationCount} convos)`,
|
|
469
|
-
})
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// 2. Find case variants (but exclude tags already marked for deletion)
|
|
474
|
-
const tagsToDeleteIds = new Set(plan.duplicatesToDelete.map((d) => d.tag.id))
|
|
475
|
-
const remainingTags = tagsWithCounts.filter((t) => !tagsToDeleteIds.has(t.id))
|
|
476
|
-
const caseVariants = findCaseVariants(remainingTags)
|
|
477
|
-
|
|
478
|
-
for (const group of caseVariants) {
|
|
479
|
-
// Sort by conversation count descending
|
|
480
|
-
const sorted = [...group.variants].sort(
|
|
481
|
-
(a, b) => b.conversationCount - a.conversationCount
|
|
482
|
-
)
|
|
483
|
-
const canonical = sorted[0]!
|
|
484
|
-
const canonicalForm = toCanonicalForm(canonical.name)
|
|
485
|
-
|
|
486
|
-
for (const variant of sorted.slice(1)) {
|
|
487
|
-
// Don't rename if already in canonical form
|
|
488
|
-
if (variant.name === canonicalForm) continue
|
|
489
|
-
|
|
490
|
-
plan.caseVariantsToRename.push({
|
|
491
|
-
tag: variant,
|
|
492
|
-
newName: canonicalForm,
|
|
493
|
-
canonical,
|
|
494
|
-
})
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// If the "canonical" tag (most convos) isn't in canonical form, rename it too
|
|
498
|
-
if (canonical.name !== canonicalForm) {
|
|
499
|
-
// Check if there's already a tag with the canonical form
|
|
500
|
-
const existingCanonical = sorted.find((t) => t.name === canonicalForm)
|
|
501
|
-
if (!existingCanonical) {
|
|
502
|
-
plan.caseVariantsToRename.push({
|
|
503
|
-
tag: canonical,
|
|
504
|
-
newName: canonicalForm,
|
|
505
|
-
canonical,
|
|
506
|
-
})
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// 3. Find obsolete tags
|
|
512
|
-
for (const tag of tagsWithCounts) {
|
|
513
|
-
if (isObsoleteTag(tag.name) && !tagsToDeleteIds.has(tag.id)) {
|
|
514
|
-
plan.obsoleteToDelete.push(tag)
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// 4. Find missing standard tags
|
|
519
|
-
const existingTagNames = new Set(
|
|
520
|
-
tagsWithCounts.map((t) => t.name.toLowerCase())
|
|
521
|
-
)
|
|
522
|
-
const categoryConfigs = Object.values(DEFAULT_CATEGORY_TAG_MAPPING)
|
|
523
|
-
for (const config of categoryConfigs) {
|
|
524
|
-
if (!existingTagNames.has(config.tagName.toLowerCase())) {
|
|
525
|
-
plan.missingToCreate.push({
|
|
526
|
-
name: config.tagName,
|
|
527
|
-
highlight: config.highlight,
|
|
528
|
-
description: config.description ?? '',
|
|
529
|
-
})
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
return plan
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* Print cleanup plan summary
|
|
538
|
-
*/
|
|
539
|
-
function printCleanupPlan(plan: CleanupPlan): void {
|
|
540
|
-
console.log('\n📋 Tag Cleanup Plan')
|
|
541
|
-
console.log('='.repeat(60))
|
|
542
|
-
|
|
543
|
-
// Duplicates to delete
|
|
544
|
-
if (plan.duplicatesToDelete.length > 0) {
|
|
545
|
-
console.log(
|
|
546
|
-
`\n🔴 Duplicates to DELETE (${plan.duplicatesToDelete.length}):`
|
|
547
|
-
)
|
|
548
|
-
for (const item of plan.duplicatesToDelete) {
|
|
549
|
-
console.log(
|
|
550
|
-
` - "${item.tag.name}" (${item.tag.conversationCount} convos) → ${item.reason}`
|
|
551
|
-
)
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// Case variants to rename
|
|
556
|
-
if (plan.caseVariantsToRename.length > 0) {
|
|
557
|
-
console.log(
|
|
558
|
-
`\n🟡 Case variants to RENAME (${plan.caseVariantsToRename.length}):`
|
|
559
|
-
)
|
|
560
|
-
for (const item of plan.caseVariantsToRename) {
|
|
561
|
-
console.log(
|
|
562
|
-
` - "${item.tag.name}" → "${item.newName}" (merge with ${item.canonical.conversationCount} convos)`
|
|
563
|
-
)
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Obsolete to delete
|
|
568
|
-
if (plan.obsoleteToDelete.length > 0) {
|
|
569
|
-
console.log(
|
|
570
|
-
`\n🗑️ Obsolete tags to DELETE (${plan.obsoleteToDelete.length}):`
|
|
571
|
-
)
|
|
572
|
-
for (const tag of plan.obsoleteToDelete) {
|
|
573
|
-
console.log(` - "${tag.name}" (${tag.conversationCount} convos)`)
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// Missing to create
|
|
578
|
-
if (plan.missingToCreate.length > 0) {
|
|
579
|
-
console.log(
|
|
580
|
-
`\n🟢 Missing standard tags to CREATE (${plan.missingToCreate.length}):`
|
|
581
|
-
)
|
|
582
|
-
for (const item of plan.missingToCreate) {
|
|
583
|
-
console.log(` - "${item.name}" (${item.highlight})`)
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// Summary totals
|
|
588
|
-
const totalChanges =
|
|
589
|
-
plan.duplicatesToDelete.length +
|
|
590
|
-
plan.caseVariantsToRename.length +
|
|
591
|
-
plan.obsoleteToDelete.length +
|
|
592
|
-
plan.missingToCreate.length
|
|
593
|
-
|
|
594
|
-
console.log('\n' + '='.repeat(60))
|
|
595
|
-
console.log(`📊 Total changes: ${totalChanges}`)
|
|
596
|
-
console.log(` - Delete duplicates: ${plan.duplicatesToDelete.length}`)
|
|
597
|
-
console.log(` - Rename variants: ${plan.caseVariantsToRename.length}`)
|
|
598
|
-
console.log(` - Delete obsolete: ${plan.obsoleteToDelete.length}`)
|
|
599
|
-
console.log(` - Create missing: ${plan.missingToCreate.length}`)
|
|
600
|
-
|
|
601
|
-
if (totalChanges === 0) {
|
|
602
|
-
console.log('\n✨ No cleanup needed - tags are in good shape!')
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
/**
|
|
607
|
-
* Execute cleanup plan
|
|
608
|
-
*/
|
|
609
|
-
async function executeCleanupPlan(
|
|
610
|
-
front: ReturnType<typeof createInstrumentedFrontClient>,
|
|
611
|
-
plan: CleanupPlan
|
|
612
|
-
): Promise<{ success: number; failed: number }> {
|
|
613
|
-
const results = { success: 0, failed: 0 }
|
|
614
|
-
|
|
615
|
-
// 1. Delete duplicates
|
|
616
|
-
for (const item of plan.duplicatesToDelete) {
|
|
617
|
-
try {
|
|
618
|
-
console.log(` Deleting duplicate "${item.tag.name}"...`)
|
|
619
|
-
await front.tags.delete(item.tag.id)
|
|
620
|
-
console.log(` ✅ Deleted "${item.tag.name}"`)
|
|
621
|
-
results.success++
|
|
622
|
-
} catch (error) {
|
|
623
|
-
console.log(
|
|
624
|
-
` ❌ Failed to delete "${item.tag.name}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
625
|
-
)
|
|
626
|
-
results.failed++
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// 2. Rename case variants (we need to merge - can't just rename if target exists)
|
|
631
|
-
for (const item of plan.caseVariantsToRename) {
|
|
632
|
-
try {
|
|
633
|
-
console.log(` Renaming "${item.tag.name}" → "${item.newName}"...`)
|
|
634
|
-
await front.tags.update(item.tag.id, { name: item.newName })
|
|
635
|
-
console.log(` ✅ Renamed "${item.tag.name}" → "${item.newName}"`)
|
|
636
|
-
results.success++
|
|
637
|
-
} catch (error) {
|
|
638
|
-
// If rename fails (maybe tag with that name exists), try to delete instead
|
|
639
|
-
const errMsg = error instanceof Error ? error.message : 'Unknown error'
|
|
640
|
-
if (errMsg.includes('already exists') || errMsg.includes('duplicate')) {
|
|
641
|
-
try {
|
|
642
|
-
console.log(` Name exists, deleting "${item.tag.name}" instead...`)
|
|
643
|
-
await front.tags.delete(item.tag.id)
|
|
644
|
-
console.log(` ✅ Deleted "${item.tag.name}" (merged into existing)`)
|
|
645
|
-
results.success++
|
|
646
|
-
} catch (delError) {
|
|
647
|
-
console.log(
|
|
648
|
-
` ❌ Failed to delete "${item.tag.name}": ${delError instanceof Error ? delError.message : 'Unknown error'}`
|
|
649
|
-
)
|
|
650
|
-
results.failed++
|
|
651
|
-
}
|
|
652
|
-
} else {
|
|
653
|
-
console.log(` ❌ Failed to rename "${item.tag.name}": ${errMsg}`)
|
|
654
|
-
results.failed++
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// 3. Delete obsolete tags
|
|
660
|
-
for (const tag of plan.obsoleteToDelete) {
|
|
661
|
-
try {
|
|
662
|
-
console.log(` Deleting obsolete "${tag.name}"...`)
|
|
663
|
-
await front.tags.delete(tag.id)
|
|
664
|
-
console.log(` ✅ Deleted "${tag.name}"`)
|
|
665
|
-
results.success++
|
|
666
|
-
} catch (error) {
|
|
667
|
-
console.log(
|
|
668
|
-
` ❌ Failed to delete "${tag.name}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
669
|
-
)
|
|
670
|
-
results.failed++
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
// 4. Create missing standard tags
|
|
675
|
-
for (const item of plan.missingToCreate) {
|
|
676
|
-
try {
|
|
677
|
-
console.log(` Creating "${item.name}"...`)
|
|
678
|
-
await createTagRaw({
|
|
679
|
-
name: item.name,
|
|
680
|
-
highlight: item.highlight,
|
|
681
|
-
description: item.description,
|
|
682
|
-
})
|
|
683
|
-
console.log(` ✅ Created "${item.name}"`)
|
|
684
|
-
results.success++
|
|
685
|
-
} catch (error) {
|
|
686
|
-
console.log(
|
|
687
|
-
` ❌ Failed to create "${item.name}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
688
|
-
)
|
|
689
|
-
results.failed++
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
return results
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* Command: skill front tags cleanup
|
|
698
|
-
* Clean up tag issues: duplicates, case variants, obsolete tags
|
|
699
|
-
*/
|
|
700
|
-
async function cleanupTags(options: { execute?: boolean }): Promise<void> {
|
|
701
|
-
try {
|
|
702
|
-
const front = getFrontSdkClient()
|
|
703
|
-
|
|
704
|
-
console.log('\n🔍 Analyzing tags...')
|
|
705
|
-
|
|
706
|
-
// Fetch all tags (raw fetch to avoid SDK validation issues)
|
|
707
|
-
const tags = await fetchTagsRaw()
|
|
708
|
-
console.log(` Found ${tags.length} tags`)
|
|
709
|
-
console.log(' Fetching conversation counts (rate-limited)...')
|
|
710
|
-
|
|
711
|
-
// Fetch conversation counts with rate limiting and progress
|
|
712
|
-
const counts = await fetchConversationCountsRateLimited(tags, front, {
|
|
713
|
-
delayMs: 150, // 150ms between batches
|
|
714
|
-
batchSize: 5, // 5 concurrent requests per batch
|
|
715
|
-
onProgress: (completed, total) => {
|
|
716
|
-
const pct = Math.round((completed / total) * 100)
|
|
717
|
-
process.stdout.write(`\r Progress: ${completed}/${total} (${pct}%)`)
|
|
718
|
-
},
|
|
719
|
-
})
|
|
720
|
-
console.log('') // newline after progress
|
|
721
|
-
|
|
722
|
-
// Build tag objects with counts
|
|
723
|
-
const tagsWithCounts: TagWithConversationCount[] = tags.map((tag) => ({
|
|
724
|
-
id: tag.id,
|
|
725
|
-
name: tag.name,
|
|
726
|
-
highlight: tag.highlight ?? null,
|
|
727
|
-
is_private: tag.is_private,
|
|
728
|
-
description: tag.description ?? null,
|
|
729
|
-
conversationCount: counts.get(tag.id) ?? 0,
|
|
730
|
-
_links: {
|
|
731
|
-
self: '',
|
|
732
|
-
related: { owner: '', children: '', conversations: '' },
|
|
733
|
-
},
|
|
734
|
-
})) as unknown as TagWithConversationCount[]
|
|
735
|
-
|
|
736
|
-
// Build cleanup plan
|
|
737
|
-
const plan = await buildCleanupPlan(front, tagsWithCounts)
|
|
738
|
-
|
|
739
|
-
// Print the plan
|
|
740
|
-
printCleanupPlan(plan)
|
|
741
|
-
|
|
742
|
-
const totalChanges =
|
|
743
|
-
plan.duplicatesToDelete.length +
|
|
744
|
-
plan.caseVariantsToRename.length +
|
|
745
|
-
plan.obsoleteToDelete.length +
|
|
746
|
-
plan.missingToCreate.length
|
|
747
|
-
|
|
748
|
-
if (totalChanges === 0) {
|
|
749
|
-
console.log('')
|
|
750
|
-
return
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// If not executing, show dry-run notice
|
|
754
|
-
if (!options.execute) {
|
|
755
|
-
console.log('\n⚠️ DRY RUN - No changes made')
|
|
756
|
-
console.log(' Use --execute to apply these changes\n')
|
|
757
|
-
return
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
// Confirm before executing
|
|
761
|
-
console.log('')
|
|
762
|
-
const confirmed = await confirm({
|
|
763
|
-
message: `Apply ${totalChanges} change(s)?`,
|
|
764
|
-
default: false,
|
|
765
|
-
})
|
|
766
|
-
|
|
767
|
-
if (!confirmed) {
|
|
768
|
-
console.log('\n❌ Cancelled.\n')
|
|
769
|
-
return
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// Execute the plan
|
|
773
|
-
console.log('\n🚀 Executing cleanup...\n')
|
|
774
|
-
const results = await executeCleanupPlan(front, plan)
|
|
775
|
-
|
|
776
|
-
// Final summary
|
|
777
|
-
console.log('\n' + '='.repeat(60))
|
|
778
|
-
console.log('📊 Cleanup Complete')
|
|
779
|
-
console.log(` ✅ Successful: ${results.success}`)
|
|
780
|
-
console.log(` ❌ Failed: ${results.failed}`)
|
|
781
|
-
console.log('')
|
|
782
|
-
} catch (error) {
|
|
783
|
-
console.error(
|
|
784
|
-
'Error:',
|
|
785
|
-
error instanceof Error ? error.message : 'Unknown error'
|
|
786
|
-
)
|
|
787
|
-
process.exit(1)
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
/**
|
|
792
|
-
* Register tag commands with Commander
|
|
793
|
-
*/
|
|
794
|
-
export function registerTagCommands(frontCommand: Command): void {
|
|
795
|
-
const tags = frontCommand.command('tags').description('Manage Front tags')
|
|
796
|
-
|
|
797
|
-
tags
|
|
798
|
-
.command('list')
|
|
799
|
-
.description('List all tags with conversation counts')
|
|
800
|
-
.option('--json', 'Output as JSON')
|
|
801
|
-
.option('--unused', 'Show only tags with 0 conversations')
|
|
802
|
-
.action(listTags)
|
|
803
|
-
|
|
804
|
-
tags
|
|
805
|
-
.command('delete')
|
|
806
|
-
.description('Delete a tag by ID')
|
|
807
|
-
.argument('<id>', 'Tag ID (e.g., tag_xxx)')
|
|
808
|
-
.option('-f, --force', 'Skip confirmation prompt')
|
|
809
|
-
.action(deleteTag)
|
|
810
|
-
|
|
811
|
-
tags
|
|
812
|
-
.command('rename')
|
|
813
|
-
.description('Rename a tag')
|
|
814
|
-
.argument('<id>', 'Tag ID (e.g., tag_xxx)')
|
|
815
|
-
.argument('<name>', 'New tag name')
|
|
816
|
-
.action(renameTag)
|
|
817
|
-
|
|
818
|
-
tags
|
|
819
|
-
.command('cleanup')
|
|
820
|
-
.description(
|
|
821
|
-
'Clean up tags: delete duplicates, merge case variants, remove obsolete, create missing standard tags'
|
|
822
|
-
)
|
|
823
|
-
.option('--execute', 'Actually apply changes (default is dry-run)', false)
|
|
824
|
-
.action(cleanupTags)
|
|
825
|
-
}
|