tspl-cli 0.0.0-stage → 0.1.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.
Files changed (75) hide show
  1. package/README.md +38 -2
  2. package/bin/tspl.js +10 -0
  3. package/package.json +41 -4
  4. package/src/active-org.js +49 -0
  5. package/src/analysis/analyzer.js +61 -0
  6. package/src/analysis/extract.js +37 -0
  7. package/src/analysis/file-filter.js +69 -0
  8. package/src/analysis/git-reader.js +108 -0
  9. package/src/analysis/html-parser.js +46 -0
  10. package/src/analysis/js-parser.js +473 -0
  11. package/src/analysis/py-parser.js +66 -0
  12. package/src/analysis/repo-index.js +107 -0
  13. package/src/api-client.js +108 -0
  14. package/src/browser-tests.js +209 -0
  15. package/src/commands/analyze.command.js +81 -0
  16. package/src/commands/auth.command.js +250 -0
  17. package/src/commands/build.command.js +267 -0
  18. package/src/commands/connect.command.js +270 -0
  19. package/src/commands/engines.command.js +136 -0
  20. package/src/commands/find.command.js +107 -0
  21. package/src/commands/init.command.js +79 -0
  22. package/src/commands/link.command.js +65 -0
  23. package/src/commands/mcp.command.js +12 -0
  24. package/src/commands/member.command.js +82 -0
  25. package/src/commands/plan.command.js +161 -0
  26. package/src/commands/project.command.js +191 -0
  27. package/src/commands/setup.command.js +63 -0
  28. package/src/commands/sync.command.js +59 -0
  29. package/src/commands/task.command.js +475 -0
  30. package/src/commands/usage.command.js +105 -0
  31. package/src/config-store.js +255 -0
  32. package/src/connect/claude-skill.md +30 -0
  33. package/src/engines.js +218 -0
  34. package/src/find/graph.js +272 -0
  35. package/src/find/search.js +473 -0
  36. package/src/find/text.js +86 -0
  37. package/src/git-helper.js +102 -0
  38. package/src/index.js +91 -0
  39. package/src/mcp/server.js +708 -0
  40. package/src/project-config.js +197 -0
  41. package/src/project-init.js +447 -0
  42. package/src/providers/github.js +249 -0
  43. package/src/providers/index.js +83 -0
  44. package/src/runtime-check.js +18 -0
  45. package/src/setup.js +180 -0
  46. package/src/shared/approaches.js +44 -0
  47. package/src/shared/constants.js +89 -0
  48. package/src/shared/index.js +8 -0
  49. package/src/shared/workflow-progress.js +67 -0
  50. package/src/shared/workflow.js +381 -0
  51. package/src/stack-detector.js +47 -0
  52. package/src/task/approaches.js +53 -0
  53. package/src/task/branch-choice.js +69 -0
  54. package/src/task/brief.js +115 -0
  55. package/src/task/build.js +549 -0
  56. package/src/task/check.js +415 -0
  57. package/src/task/conventions.js +113 -0
  58. package/src/task/gate.js +88 -0
  59. package/src/task/hooks.js +82 -0
  60. package/src/task/plan-draft.js +43 -0
  61. package/src/task/plan-page.js +123 -0
  62. package/src/task/plan.js +293 -0
  63. package/src/task/planning-context.js +165 -0
  64. package/src/task/refine.js +73 -0
  65. package/src/workflow/agents.js +336 -0
  66. package/src/workflow/checkout-lock.js +92 -0
  67. package/src/workflow/client.js +82 -0
  68. package/src/workflow/controller.js +585 -0
  69. package/src/workflow/evidence.js +276 -0
  70. package/src/workflow/pr-body.js +52 -0
  71. package/src/workflow/process.js +82 -0
  72. package/src/workflow/review.js +128 -0
  73. package/src/workflow/runtime.js +148 -0
  74. package/src/workflow/wait.js +58 -0
  75. package/src/workspace.js +250 -0
package/README.md CHANGED
@@ -1,3 +1,39 @@
1
- # Temporary Holding Version
1
+ # tspl
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `tspl` is the command-line tool for the TSPL AI feature-development tool. It reads your repositories (no AI, nothing leaves your machine), turns a task into a clear brief, plans the change with you, enforces the approved plan while an AI tool builds it, runs the tests and opens the pull request.
4
+
5
+ ## Requirements
6
+
7
+ - Node 22 or newer
8
+ - git
9
+ - Access to a TSPL API server (ask your team for its address)
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install -g tspl-cli
15
+ tspl --help
16
+ ```
17
+
18
+ ## Get started
19
+
20
+ ```bash
21
+ tspl setup --api https://your-tspl-api.example.com # log in and check your machine
22
+ cd path/to/your/project
23
+ tspl init # link this folder to a TSPL project
24
+ tspl connect # add tspl to your AI tool (MCP)
25
+ tspl task new # describe a task
26
+ ```
27
+
28
+ `tspl setup` with no `--api` uses `http://localhost:1500`, for a TSPL API running on your own machine.
29
+
30
+ ## Where tspl keeps data
31
+
32
+ - `~/.tspl`: your login, settings and the local code map of each repo. The map is built from git and never uploaded.
33
+ - `.tspl/project.json` in your project: the project's checks and settings, meant to be committed.
34
+
35
+ ## Update
36
+
37
+ ```bash
38
+ npm install -g tspl-cli@latest
39
+ ```
package/bin/tspl.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ const problem = require('../src/runtime-check').checkRuntime()
5
+ if (problem) {
6
+ console.error(problem)
7
+ process.exit(1)
8
+ }
9
+
10
+ require('../src/index').run(process.argv)
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "tspl-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "TSPL command-line tool: learns your code, plans changes with you, builds, tests and opens pull requests",
5
+ "keywords": [
6
+ "tspl",
7
+ "cli",
8
+ "ai",
9
+ "mcp",
10
+ "claude",
11
+ "code-review",
12
+ "planning"
13
+ ],
14
+ "license": "UNLICENSED",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/meet2amit/tspl-pm-aitool.git",
18
+ "directory": "apps/cli"
19
+ },
20
+ "bin": {
21
+ "tspl": "bin/tspl.js"
22
+ },
23
+ "main": "src/index.js",
24
+ "files": [
25
+ "bin",
26
+ "src",
27
+ "README.md"
28
+ ],
29
+ "engines": {
30
+ "node": ">=22"
31
+ },
32
+ "dependencies": {
33
+ "@babel/parser": "^7.29.9",
34
+ "@inquirer/prompts": "^8.7.2",
35
+ "@modelcontextprotocol/sdk": "^1.31.0",
36
+ "commander": "^15.0.0",
37
+ "zod": "^4.6.5"
38
+ },
39
+ "scripts": {},
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }
@@ -0,0 +1,49 @@
1
+ 'use strict'
2
+
3
+ const Fs = require('fs')
4
+ const Path = require('path')
5
+
6
+ const { readLinks, accountFor, setActiveOrganization } = require('./config-store')
7
+ const { findProjectConfig } = require('./project-config')
8
+
9
+ const inside = (dir, parent) => Boolean(parent) && (dir === parent || dir.startsWith(`${parent}${Path.sep}`))
10
+
11
+ const real = dir => {
12
+ try {
13
+ return Fs.realpathSync(dir)
14
+ } catch (err) {
15
+ return Path.resolve(dir)
16
+ }
17
+ }
18
+
19
+ /**
20
+ * The organization a folder belongs to: the one named in its .tspl/project.json, else the one whose linked project
21
+ * contains the folder. null when the folder is not part of a project or the link is ambiguous.
22
+ */
23
+ const organizationOfFolder = cwd => {
24
+ try {
25
+ const found = findProjectConfig(cwd)
26
+ if (found) return found.config.organization
27
+ } catch (err) {
28
+ // a broken project file is reported by the command that needs it
29
+ }
30
+ const dir = real(cwd)
31
+ const orgs = new Set(
32
+ Object.entries(readLinks())
33
+ .filter(([, link]) => inside(dir, link.folder && real(link.folder)) || Object.values(link.repos || {}).some(repo => inside(dir, repo.path && real(repo.path))))
34
+ .map(([key]) => key.split('/')[0]),
35
+ )
36
+ return orgs.size === 1 ? [...orgs][0] : null
37
+ }
38
+
39
+ /** Makes every command in this folder use the login of the folder's organization (when this machine has one). */
40
+ const useFolderOrganization = cwd => {
41
+ const organization = organizationOfFolder(cwd)
42
+ if (organization && accountFor(organization)) setActiveOrganization(organization)
43
+ return organization
44
+ }
45
+
46
+ module.exports = {
47
+ organizationOfFolder,
48
+ useFolderOrganization,
49
+ }
@@ -0,0 +1,61 @@
1
+ 'use strict'
2
+
3
+ const { readIndex, writeIndex } = require('../config-store')
4
+ const { buildRepoIndex } = require('./repo-index')
5
+
6
+ const COUNTED = ['routes', 'models', 'uiRoutes', 'screens', 'components', 'httpCalls']
7
+
8
+ const refreshIndex = async (project, repos, onRepo = () => {}, saved = readIndex(project._id)) => {
9
+ const index = { projectId: project._id, repos: { ...(saved.repos || {}) } }
10
+ let changed = false
11
+ for (const repo of repos) {
12
+ try {
13
+ const repoIndex = await buildRepoIndex(repo, index.repos[repo._id])
14
+ const isNew = Boolean(repoIndex.newlyParsed || !index.repos[repo._id] || index.repos[repo._id].commitSha !== repoIndex.commitSha)
15
+ changed = changed || isNew
16
+ index.repos[repo._id] = repoIndex
17
+ onRepo(repo, repoIndex, null, isNew)
18
+ } catch (err) {
19
+ onRepo(repo, null, err)
20
+ }
21
+ }
22
+ const linked = new Set(repos.map(repo => repo._id))
23
+ Object.keys(index.repos)
24
+ .filter(id => !linked.has(id))
25
+ .forEach(id => {
26
+ delete index.repos[id]
27
+ changed = true
28
+ })
29
+ if (changed) {
30
+ writeIndex(project._id, index)
31
+ }
32
+ return index
33
+ }
34
+
35
+ const describeRepo = repoIndex => {
36
+ const stats = { byStatus: {} }
37
+ COUNTED.forEach(key => { stats[key] = 0 })
38
+ repoIndex.files.forEach(file => {
39
+ stats.byStatus[file.status] = (stats.byStatus[file.status] || 0) + 1
40
+ })
41
+ Object.values(repoIndex.facts).forEach(facts => {
42
+ if (!facts) return
43
+ COUNTED.forEach(key => {
44
+ if (Array.isArray(facts[key])) stats[key] += facts[key].length
45
+ })
46
+ })
47
+ const codeFiles = Object.keys(repoIndex.facts).length
48
+ return {
49
+ branch: repoIndex.branch,
50
+ commitSha: repoIndex.commitSha,
51
+ date: repoIndex.date,
52
+ codeFiles,
53
+ newlyAnalyzed: repoIndex.newlyParsed || 0,
54
+ stats,
55
+ }
56
+ }
57
+
58
+ module.exports = {
59
+ refreshIndex,
60
+ describeRepo,
61
+ }
@@ -0,0 +1,37 @@
1
+ 'use strict'
2
+
3
+ const Path = require('path')
4
+ const { extractJs } = require('./js-parser')
5
+ const { extractPy } = require('./py-parser')
6
+ const { extractHtml } = require('./html-parser')
7
+
8
+ const PARSER_VERSION = 3
9
+
10
+ const LANGUAGES = {
11
+ '.js': 'javascript',
12
+ '.jsx': 'javascript',
13
+ '.mjs': 'javascript',
14
+ '.cjs': 'javascript',
15
+ '.ts': 'typescript',
16
+ '.tsx': 'typescript',
17
+ '.py': 'python',
18
+ '.html': 'html',
19
+ }
20
+
21
+ const withoutEmpty = facts =>
22
+ Object.fromEntries(
23
+ Object.entries(facts).filter(([, value]) => !(Array.isArray(value) && !value.length)),
24
+ )
25
+
26
+ const extractFacts = (path, code) => {
27
+ const ext = Path.extname(path).toLowerCase()
28
+ const language = LANGUAGES[ext]
29
+ const parsers = { python: extractPy, html: extractHtml }
30
+ const facts = parsers[language] ? parsers[language](code) : extractJs(code, ext)
31
+ return withoutEmpty({ language, lines: code.split('\n').length, ...facts })
32
+ }
33
+
34
+ module.exports = {
35
+ PARSER_VERSION,
36
+ extractFacts,
37
+ }
@@ -0,0 +1,69 @@
1
+ 'use strict'
2
+
3
+ const Path = require('path')
4
+
5
+ const MAX_BYTES = 512 * 1024
6
+
7
+ const CODE_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.py', '.html'])
8
+
9
+ const SECRET_PATTERNS = [
10
+ /(^|\/)\.env(\.[^/]*)?$/i,
11
+ /\.(pem|key|p12|pfx|jks|keystore|p8|mobileprovision|cer|crt|der|asc|gpg)$/i,
12
+ /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i,
13
+ /(^|\/)\.(npmrc|pypirc|netrc)$/i,
14
+ /(^|\/)google-services\.json$/i,
15
+ /(^|\/)GoogleService-Info\.plist$/i,
16
+ /(^|\/)service-account[^/]*\.json$/i,
17
+ /(^|\/)\.(aws|ssh)\//i,
18
+ ]
19
+
20
+ const SECRET_NAME = /(^|[._-])(secrets?|credentials?)([._-]|$)/i
21
+
22
+ const GENERATED_PATTERNS = [
23
+ /(^|\/)(node_modules|bower_components|dist|build|coverage|vendor|\.next|\.expo|\.nuxt|Pods|\.gradle)\//,
24
+ /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|npm-shrinkwrap\.json|Podfile\.lock)$/,
25
+ /\.(min\.js|min\.css|map|snap|lock)$/i,
26
+ ]
27
+
28
+ const BINARY_EXTENSIONS = new Set([
29
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tif', '.tiff', '.heic',
30
+ '.mp4', '.mov', '.avi', '.mp3', '.wav', '.ogg', '.m4a',
31
+ '.pdf', '.zip', '.gz', '.tgz', '.rar', '.7z', '.jar', '.war', '.tar',
32
+ '.woff', '.woff2', '.ttf', '.otf', '.eot',
33
+ '.psd', '.ai', '.sketch', '.fig', '.xls', '.xlsx', '.doc', '.docx', '.ppt', '.pptx',
34
+ '.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.db', '.sqlite', '.apk', '.ipa', '.aab',
35
+ ])
36
+
37
+ const SECRET_VALUE_NAME = /(key|secret|token|passw|pwd|auth|credential|private|salt|signature|cert|bearer|session|cookie|dsn)/i
38
+ const SECRET_VALUE = /-----BEGIN|^(sk|pk|rk|ghp|gho|xox[abp])[-_]|^AKIA|^eyJ|:\/\/[^\s/]*:[^\s/]*@|(password|pwd|secret|token)=/i
39
+
40
+ const isSecretValue = (name, value) => {
41
+ const text = String(value)
42
+ if ((name && SECRET_VALUE_NAME.test(name)) || SECRET_VALUE.test(text)) return true
43
+ return !/\s/.test(text) && text.length >= 20 && /[a-z]/i.test(text) && /\d/.test(text)
44
+ }
45
+
46
+ const classify = (path, size) => {
47
+ if (
48
+ SECRET_PATTERNS.some(pattern => pattern.test(path)) ||
49
+ SECRET_NAME.test(Path.basename(path))
50
+ ) {
51
+ return 'skipped-secret'
52
+ }
53
+ if (GENERATED_PATTERNS.some(pattern => pattern.test(path))) {
54
+ return 'skipped-generated'
55
+ }
56
+ const ext = Path.extname(path).toLowerCase()
57
+ if (BINARY_EXTENSIONS.has(ext)) {
58
+ return 'skipped-binary'
59
+ }
60
+ if (size > MAX_BYTES) {
61
+ return 'skipped-large'
62
+ }
63
+ return CODE_EXTENSIONS.has(ext) ? 'parsed' : 'listed'
64
+ }
65
+
66
+ module.exports = {
67
+ classify,
68
+ isSecretValue,
69
+ }
@@ -0,0 +1,108 @@
1
+ 'use strict'
2
+
3
+ const { execFileSync, spawn } = require('child_process')
4
+
5
+ const git = (dir, args, options = {}) =>
6
+ execFileSync('git', ['-C', dir, ...args], {
7
+ encoding: options.encoding || 'utf8',
8
+ maxBuffer: 256 * 1024 * 1024,
9
+ stdio: ['ignore', 'pipe', 'ignore'],
10
+ })
11
+
12
+ const verify = (dir, ref) => {
13
+ try {
14
+ return git(dir, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).trim()
15
+ } catch (err) {
16
+ return null
17
+ }
18
+ }
19
+
20
+ const resolveRef = (dir, branch) => {
21
+ const candidates = branch
22
+ ? [`refs/remotes/origin/${branch}`, `refs/heads/${branch}`]
23
+ : ['HEAD']
24
+ for (const ref of candidates) {
25
+ const commitSha = verify(dir, ref)
26
+ if (commitSha) {
27
+ const date = git(dir, ['log', '-1', '--format=%cs', commitSha]).trim()
28
+ return { ref, commitSha, date }
29
+ }
30
+ }
31
+ throw new Error(branch ? `branch "${branch}" not found` : 'no commits found')
32
+ }
33
+
34
+ const listFiles = (dir, ref) =>
35
+ git(dir, ['ls-tree', '-r', '-l', '-z', ref])
36
+ .split('\0')
37
+ .filter(Boolean)
38
+ .map(line => {
39
+ const tab = line.indexOf('\t')
40
+ const [, type, blobHash, size] = line.slice(0, tab).split(/\s+/)
41
+ return { type, blobHash, size: Number(size) || 0, path: line.slice(tab + 1) }
42
+ })
43
+ .filter(entry => entry.type === 'blob')
44
+ .map(({ blobHash, size, path }) => ({ blobHash, size, path }))
45
+
46
+ const HISTORY_COMMITS = 4000
47
+ const HISTORY_PER_FILE = 40
48
+ const HISTORY_MAX_FILES = 30
49
+
50
+ const fileHistory = (dir, ref, wanted) => {
51
+ const history = {}
52
+ const log = git(dir, ['-c', 'core.quotePath=false', 'log', ref, '--no-merges', '-n', String(HISTORY_COMMITS), '--name-only', '--format=%x00%s'])
53
+ log
54
+ .split('\0')
55
+ .filter(Boolean)
56
+ .forEach(block => {
57
+ const [message, ...paths] = block.split('\n').map(line => line.trim())
58
+ const files = paths.filter(path => path && wanted.has(path))
59
+ if (!message || /^(merge|squashed commit)/i.test(message) || paths.filter(Boolean).length > HISTORY_MAX_FILES) return
60
+ files.forEach(path => {
61
+ const messages = history[path] || (history[path] = [])
62
+ if (messages.length < HISTORY_PER_FILE && !messages.includes(message)) messages.push(message)
63
+ })
64
+ })
65
+ return history
66
+ }
67
+
68
+ const readBlobs = (dir, hashes) =>
69
+ new Promise((resolve, reject) => {
70
+ const child = spawn('git', ['-C', dir, 'cat-file', '--batch'], {
71
+ stdio: ['pipe', 'pipe', 'ignore'],
72
+ })
73
+ const chunks = []
74
+ child.stdout.on('data', chunk => chunks.push(chunk))
75
+ child.on('error', reject)
76
+ child.on('close', code => {
77
+ if (code !== 0) {
78
+ reject(new Error(`git cat-file failed in ${dir}`))
79
+ return
80
+ }
81
+ const buffer = Buffer.concat(chunks)
82
+ const blobs = new Map()
83
+ let offset = 0
84
+ while (offset < buffer.length) {
85
+ const newline = buffer.indexOf(0x0a, offset)
86
+ if (newline === -1) {
87
+ break
88
+ }
89
+ const [hash, type, size] = buffer.toString('utf8', offset, newline).split(' ')
90
+ offset = newline + 1
91
+ if (type === 'missing') {
92
+ continue
93
+ }
94
+ const length = Number(size)
95
+ blobs.set(hash, buffer.toString('utf8', offset, offset + length))
96
+ offset += length + 1
97
+ }
98
+ resolve(blobs)
99
+ })
100
+ child.stdin.end(`${hashes.join('\n')}\n`)
101
+ })
102
+
103
+ module.exports = {
104
+ resolveRef,
105
+ listFiles,
106
+ fileHistory,
107
+ readBlobs,
108
+ }
@@ -0,0 +1,46 @@
1
+ 'use strict'
2
+
3
+ const LIMIT = 500
4
+ const LABEL_LIMIT = 150
5
+
6
+ const CUSTOM_TAG = /<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)[\s/>]/g
7
+ const LABEL_ATTRIBUTE = /\s(?:title|placeholder|label|alt|aria-label|matTooltip|ngbTooltip)\s*=\s*"([^"{}]+)"/g
8
+ const ROUTER_LINK = /\srouterLink\s*=\s*"(\/[^"{}]*)"/g
9
+
10
+ const labelText = value => {
11
+ const label = value.replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
12
+ return label.length >= 2 && label.length <= 80 && /[a-z]/i.test(label) ? label : null
13
+ }
14
+
15
+ const extractHtml = code => {
16
+ const tags = new Set()
17
+ const labels = new Set()
18
+ const links = new Set()
19
+ const markup = code
20
+ .replace(/<!--[\s\S]*?-->/g, ' ')
21
+ .replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
22
+
23
+ for (const match of markup.matchAll(CUSTOM_TAG)) tags.add(match[1])
24
+ for (const match of markup.matchAll(ROUTER_LINK)) links.add(match[1])
25
+ for (const match of markup.matchAll(LABEL_ATTRIBUTE)) {
26
+ const label = labelText(match[1])
27
+ if (label) labels.add(label)
28
+ }
29
+ markup
30
+ .replace(/\{\{[\s\S]*?\}\}/g, ' ')
31
+ .split(/<[^>]*>/)
32
+ .forEach(chunk => {
33
+ const label = labelText(chunk)
34
+ if (label) labels.add(label)
35
+ })
36
+
37
+ return {
38
+ tags: [...tags].slice(0, LIMIT),
39
+ links: [...links].slice(0, LIMIT),
40
+ labels: [...labels].slice(0, LABEL_LIMIT),
41
+ }
42
+ }
43
+
44
+ module.exports = {
45
+ extractHtml,
46
+ }