├── cfm_package ├── py.typed ├── __main__.py ├── __init__.py ├── cli.py ├── cfm_mcp_server.py └── main.py ├── .vscode └── settings.json ├── requirements.txt ├── MANIFEST.in ├── claude_desktop_config_example.json ├── requirements-dev.txt ├── LICENSE ├── CHANGELOG.md ├── pyproject.toml ├── upload_to_pypi.sh ├── setup.py ├── .gitignore ├── PYPI_UPLOAD_INSTRUCTIONS.md ├── .github └── workflows │ ├── publish-to-pypi.yml │ └── version-and-release.yml ├── auto_version.py ├── README.md └── cfm /cfm_package/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.languageServer": "None" 3 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # No runtime dependencies required 2 | # This project uses only Python standard library modules -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include pyproject.toml 4 | recursive-exclude * __pycache__ 5 | recursive-exclude * *.py[cod] -------------------------------------------------------------------------------- /claude_desktop_config_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "context-file-manager": { 4 | "command": "cfm-mcp" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /cfm_package/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | CLI entry point for Context File Manager 3 | """ 4 | 5 | from .main import main 6 | 7 | if __name__ == "__main__": 8 | main() -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # Development dependencies 2 | pytest>=7.0 3 | pytest-cov>=4.0 4 | black>=23.0 5 | flake8>=6.0 6 | mypy>=1.0 7 | build>=0.10.0 8 | twine>=4.0.2 -------------------------------------------------------------------------------- /cfm_package/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Context File Manager - A CLI tool for managing shared context files across projects 3 | """ 4 | 5 | __version__ = "0.5.2" 6 | __author__ = "Anand Tyagi" 7 | __email__ = "anand.deep.tyagi@gmail.com" 8 | 9 | from .main import ContextFileManager 10 | 11 | __all__ = ["ContextFileManager"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Anand Tyagi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [0.5.2] - 2025-06-22 6 | 7 | ### Changes 8 | c6ae38a patch recompile package 9 | 10 | ## [0.5.1] - 2025-06-22 11 | 12 | ### Changes 13 | a86d83e fix: add mcp setup command in help 14 | 15 | ## [0.5.0] - 2025-06-22 16 | 17 | ### Changes 18 | 9877fa5 chore: release v0.4.0 19 | 3a8219b chore: update setup.py with MCP extras and entry points 20 | 14a485e feat: add setup-mcp command to generate Claude Desktop config 21 | 22 | ## [0.4.0] - 2025-06-22 23 | 24 | ### Changes 25 | 3a8219b chore: update setup.py with MCP extras and entry points 26 | 14a485e feat: add setup-mcp command to generate Claude Desktop config 27 | 28 | ## [0.3.0] - 2025-06-22 29 | 30 | ### Changes 31 | 01f3c00 add mcp server 32 | 33 | ## [0.2.0] - 2025-06-20 34 | 35 | ### Changes 36 | a473cf9 feat: add folder support 37 | 38 | ## [0.1.1] - 2025-06-17 39 | 40 | ### Changes 41 | a8b595f updated github workflows 42 | 7cd0a97 add auto versioning script 43 | fad5410 remove version and release github workflow 44 | 47fef79 create cfm package 45 | 790671b cfm cli tool 46 | 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "context-file-manager" 7 | version = "0.5.2" 8 | authors = [ 9 | { name="Anand Tyagi", email="anand.deep.tyagi@gmail.com" }, 10 | ] 11 | description = "A CLI tool for managing shared context files across projects" 12 | readme = "README.md" 13 | requires-python = ">=3.7" 14 | classifiers = [ 15 | "Development Status :: 3 - Alpha", 16 | "Intended Audience :: Developers", 17 | "Topic :: Software Development :: Libraries :: Python Modules", 18 | "Topic :: System :: Filesystems", 19 | "License :: OSI Approved :: MIT License", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3.7", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Operating System :: OS Independent", 28 | ] 29 | keywords = ["file management", "context files", "cli tool", "project management"] 30 | 31 | dependencies = [] 32 | 33 | [project.optional-dependencies] 34 | mcp = ["mcp>=1.0.0"] 35 | all = ["mcp>=1.0.0"] 36 | 37 | [project.urls] 38 | "Homepage" = "https://github.com/ananddtyagi/context-file-manager" 39 | "Bug Tracker" = "https://github.com/ananddtyagi/context-file-manager/issues" 40 | "Source" = "https://github.com/ananddtyagi/context-file-manager" 41 | 42 | [project.scripts] 43 | cfm = "cfm_package.main:main" 44 | cfm-mcp = "cfm_package.cfm_mcp_server:main" 45 | 46 | [tool.setuptools.packages.find] 47 | where = ["."] 48 | include = ["cfm_package*"] -------------------------------------------------------------------------------- /upload_to_pypi.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Upload Context File Manager to PyPI 4 | # Usage: ./upload_to_pypi.sh [test|prod] 5 | 6 | set -e 7 | 8 | # Change to the directory containing this script 9 | cd "$(dirname "$0")" 10 | 11 | # Parse arguments 12 | ENVIRONMENT=${1:-test} 13 | 14 | if [[ "$ENVIRONMENT" != "test" && "$ENVIRONMENT" != "prod" ]]; then 15 | echo "Usage: $0 [test|prod]" 16 | echo " test: Upload to TestPyPI" 17 | echo " prod: Upload to PyPI" 18 | exit 1 19 | fi 20 | 21 | echo "📦 Building package..." 22 | 23 | # Clean previous builds 24 | rm -rf dist/ build/ *.egg-info/ 25 | 26 | # Install build dependencies 27 | pip install --upgrade build twine 28 | 29 | # Build the package 30 | python -m build 31 | 32 | echo "✅ Package built successfully" 33 | 34 | if [[ "$ENVIRONMENT" == "test" ]]; then 35 | echo "🚀 Uploading to TestPyPI..." 36 | twine upload --repository testpypi dist/* 37 | echo "✅ Uploaded to TestPyPI" 38 | echo "📥 Test installation with:" 39 | echo "pip install --index-url https://test.pypi.org/simple/ context-file-manager" 40 | echo "pip install --index-url https://test.pypi.org/simple/ context-file-manager[mcp]" 41 | else 42 | echo "🚀 Uploading to PyPI..." 43 | read -p "Are you sure you want to upload to production PyPI? (y/N): " confirm 44 | if [[ $confirm == [yY] || $confirm == [yY][eE][sS] ]]; then 45 | twine upload dist/* 46 | echo "✅ Uploaded to PyPI" 47 | echo "📥 Install with:" 48 | echo "pip install context-file-manager # CLI only" 49 | echo "pip install context-file-manager[mcp] # With MCP server" 50 | echo "pip install context-file-manager[all] # Everything" 51 | else 52 | echo "❌ Upload cancelled" 53 | exit 1 54 | fi 55 | fi -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Setup configuration for Context File Manager 3 | """ 4 | 5 | from setuptools import setup, find_packages 6 | from pathlib import Path 7 | 8 | # Read the README file 9 | this_directory = Path(__file__).parent 10 | long_description = (this_directory / "README.md").read_text(encoding="utf-8") 11 | 12 | setup( 13 | name="context-file-manager", 14 | version="0.5.2", 15 | author="Anand Tyagi", 16 | author_email="anand.deep.tyagi@gmail.com", 17 | description="A CLI tool for managing shared context files across projects", 18 | long_description=long_description, 19 | long_description_content_type="text/markdown", 20 | url="https://github.com/ananddtyagi/context-file-manager", 21 | packages=find_packages(), 22 | classifiers=[ 23 | "Development Status :: 3 - Alpha", 24 | "Intended Audience :: Developers", 25 | "Topic :: Software Development :: Libraries :: Python Modules", 26 | "Topic :: System :: Filesystems", 27 | "License :: OSI Approved :: MIT License", 28 | "Programming Language :: Python :: 3", 29 | "Programming Language :: Python :: 3.7", 30 | "Programming Language :: Python :: 3.8", 31 | "Programming Language :: Python :: 3.9", 32 | "Programming Language :: Python :: 3.10", 33 | "Programming Language :: Python :: 3.11", 34 | "Programming Language :: Python :: 3.12", 35 | "Operating System :: OS Independent", 36 | ], 37 | python_requires=">=3.7", 38 | install_requires=[], 39 | extras_require={ 40 | "mcp": ["mcp>=1.0.0"], 41 | "all": ["mcp>=1.0.0"], 42 | }, 43 | entry_points={ 44 | "console_scripts": [ 45 | "cfm=cfm_package.main:main", 46 | "cfm-mcp=cfm_package.cfm_mcp_server:main", 47 | ], 48 | }, 49 | keywords="file management, context files, cli tool, project management", 50 | project_urls={ 51 | "Bug Reports": "https://github.com/ananddtyagi/context-file-manager/issues", 52 | "Source": "https://github.com/ananddtyagi/context-file-manager", 53 | }, 54 | ) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .nox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | *.py,cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | cover/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | db.sqlite3-journal 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | .pybuilder/ 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | Pipfile.lock 88 | 89 | # poetry 90 | poetry.lock 91 | 92 | # pdm 93 | .pdm.toml 94 | 95 | # PEP 582 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | # pytype static type analyzer 133 | .pytype/ 134 | 135 | # Cython debug symbols 136 | cython_debug/ 137 | 138 | # IDEs 139 | .idea/ 140 | .vscode/ 141 | *.swp 142 | *.swo 143 | *~ 144 | 145 | # OS 146 | .DS_Store 147 | .DS_Store? 148 | ._* 149 | .Spotlight-V100 150 | .Trashes 151 | ehthumbs.db 152 | Thumbs.db -------------------------------------------------------------------------------- /PYPI_UPLOAD_INSTRUCTIONS.md: -------------------------------------------------------------------------------- 1 | # PyPI Upload Instructions 2 | 3 | This guide explains how to build and upload the Context File Manager package to PyPI. 4 | 5 | ## Prerequisites 6 | 7 | 1. Create a PyPI account at https://pypi.org/account/register/ 8 | 2. Install build and upload tools: 9 | ```bash 10 | pip install build twine 11 | ``` 12 | 13 | ## Building the Package 14 | 15 | 1. Clean any previous builds: 16 | ```bash 17 | rm -rf dist/ build/ *.egg-info/ 18 | ``` 19 | 20 | 2. Build the package: 21 | ```bash 22 | python -m build 23 | ``` 24 | 25 | This creates: 26 | - `dist/context-file-manager-version.tar.gz` (source distribution) 27 | - `dist/context_file_manager-version-py3-none-any.whl` (wheel distribution) 28 | 29 | ## Testing Locally 30 | 31 | Before uploading to PyPI, test the package locally: 32 | 33 | ```bash 34 | pip install dist/context_file_manager-0.1.0-py3-none-any.whl 35 | cfm --help 36 | ``` 37 | 38 | ## Uploading to PyPI 39 | 40 | 1. Upload to PyPI: 41 | ```bash 42 | python -m twine upload dist/* 43 | ``` 44 | 45 | You'll be prompted for your PyPI username and password. 46 | 47 | 2. Or use an API token (recommended): 48 | - Generate a token at https://pypi.org/manage/account/token/ 49 | - Use `__token__` as username and the token as password 50 | 51 | ## Using .pypirc for Authentication 52 | 53 | Create `~/.pypirc` for easier uploads: 54 | 55 | ```ini 56 | [distutils] 57 | index-servers = 58 | pypi 59 | testpypi 60 | 61 | [pypi] 62 | username = __token__ 63 | password = pypi-your-api-token-here 64 | 65 | [testpypi] 66 | username = __token__ 67 | password = pypi-your-testpypi-token-here 68 | ``` 69 | 70 | Then upload without entering credentials: 71 | ```bash 72 | python -m twine upload dist/* 73 | ``` 74 | 75 | ## Version Updates 76 | 77 | When releasing new versions: 78 | 79 | 1. Update version in: 80 | - `cfm_package/__init__.py` 81 | - `setup.py` 82 | - `pyproject.toml` 83 | 84 | 2. Rebuild and upload as above 85 | 86 | ## Verification 87 | 88 | After uploading, verify the package: 89 | 90 | 1. Visit https://pypi.org/project/context-file-manager/ 91 | 2. Install in a fresh environment: 92 | ```bash 93 | pip install context-file-manager 94 | ``` 95 | 3. Test the CLI: 96 | ```bash 97 | cfm --help 98 | ``` 99 | 100 | ## Common Issues 101 | 102 | - **Name conflicts**: If the package name is taken, update it in `setup.py` and `pyproject.toml` 103 | - **Missing files**: Ensure `MANIFEST.in` includes all necessary files 104 | - **Import errors**: Test the package structure locally before uploading 105 | - **Authentication**: Use API tokens instead of passwords for better security -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | inputs: 8 | environment: 9 | description: 'Environment to publish to' 10 | required: true 11 | default: 'testpypi' 12 | type: choice 13 | options: 14 | - testpypi 15 | - pypi 16 | 17 | jobs: 18 | publish: 19 | runs-on: ubuntu-latest 20 | environment: 21 | name: ${{ github.event_name == 'release' && 'pypi' || github.event.inputs.environment }} 22 | url: ${{ github.event_name == 'release' && 'https://pypi.org/p/context-file-manager' || 'https://test.pypi.org/p/context-file-manager' }} 23 | permissions: 24 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 25 | contents: read 26 | 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Setup Python 32 | uses: actions/setup-python@v4 33 | with: 34 | python-version: '3.11' 35 | 36 | - name: Install build dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | pip install build twine 40 | 41 | - name: Build package 42 | run: python -m build 43 | 44 | - name: Check package 45 | run: python -m twine check dist/* 46 | 47 | - name: Publish to Test PyPI 48 | if: github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'testpypi' 49 | uses: pypa/gh-action-pypi-publish@release/v1 50 | with: 51 | repository-url: https://test.pypi.org/legacy/ 52 | print-hash: true 53 | 54 | - name: Publish to PyPI 55 | if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'pypi') 56 | uses: pypa/gh-action-pypi-publish@release/v1 57 | with: 58 | print-hash: true 59 | 60 | - name: Create deployment summary 61 | run: | 62 | echo "## 🚀 Package Published Successfully" >> $GITHUB_STEP_SUMMARY 63 | echo "" >> $GITHUB_STEP_SUMMARY 64 | echo "### Package Details" >> $GITHUB_STEP_SUMMARY 65 | echo "- **Package**: context-file-manager" >> $GITHUB_STEP_SUMMARY 66 | echo "- **Version**: $(python -c "import toml; print(toml.load('pyproject.toml')['project']['version'])")" >> $GITHUB_STEP_SUMMARY 67 | if [ "${{ github.event_name }}" = "release" ] || [ "${{ github.event.inputs.environment }}" = "pypi" ]; then 68 | echo "- **Repository**: PyPI (Production)" >> $GITHUB_STEP_SUMMARY 69 | echo "- **URL**: https://pypi.org/project/context-file-manager/" >> $GITHUB_STEP_SUMMARY 70 | echo "- **Install**: \`pip install context-file-manager\`" >> $GITHUB_STEP_SUMMARY 71 | else 72 | echo "- **Repository**: Test PyPI" >> $GITHUB_STEP_SUMMARY 73 | echo "- **URL**: https://test.pypi.org/project/context-file-manager/" >> $GITHUB_STEP_SUMMARY 74 | echo "- **Install**: \`pip install -i https://test.pypi.org/simple/ context-file-manager\`" >> $GITHUB_STEP_SUMMARY 75 | fi -------------------------------------------------------------------------------- /.github/workflows/version-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Version and Release 2 | 3 | on: 4 | push: 5 | branches: [ main, master ] 6 | workflow_dispatch: 7 | inputs: 8 | version_type: 9 | description: 'Version bump type' 10 | required: true 11 | default: 'patch' 12 | type: choice 13 | options: 14 | - patch 15 | - minor 16 | - major 17 | 18 | jobs: 19 | version-and-release: 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: write 23 | issues: write 24 | pull-requests: write 25 | 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 0 31 | token: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Setup Python 34 | uses: actions/setup-python@v4 35 | with: 36 | python-version: '3.11' 37 | 38 | - name: Install dependencies 39 | run: | 40 | python -m pip install --upgrade pip 41 | pip install build twine toml 42 | 43 | - name: Build project 44 | run: python -m build 45 | 46 | - name: Configure Git 47 | run: | 48 | git config user.name "github-actions[bot]" 49 | git config user.email "github-actions[bot]@users.noreply.github.com" 50 | 51 | - name: Get current version 52 | id: current_version 53 | run: echo "version=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['version'])")" >> $GITHUB_OUTPUT 54 | 55 | - name: Determine version bump type 56 | id: version_type 57 | run: | 58 | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then 59 | echo "type=${{ github.event.inputs.version_type }}" >> $GITHUB_OUTPUT 60 | else 61 | # Check commit message for conventional commits 62 | commit_msg="${{ github.event.head_commit.message }}" 63 | if echo "$commit_msg" | grep -qE "^feat(\(.+\))?!:|^fix(\(.+\))?!:|BREAKING CHANGE"; then 64 | echo "type=major" >> $GITHUB_OUTPUT 65 | elif echo "$commit_msg" | grep -qE "^feat(\(.+\))?:"; then 66 | echo "type=minor" >> $GITHUB_OUTPUT 67 | else 68 | echo "type=patch" >> $GITHUB_OUTPUT 69 | fi 70 | fi 71 | 72 | - name: Bump version 73 | id: version 74 | run: | 75 | python auto_version.py 76 | new_version=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['version'])") 77 | echo "new_version=$new_version" >> $GITHUB_OUTPUT 78 | echo "tag=v$new_version" >> $GITHUB_OUTPUT 79 | 80 | - name: Create changelog entry 81 | run: | 82 | if [ ! -f CHANGELOG.md ]; then 83 | cat > CHANGELOG.md << 'EOF' 84 | # Changelog 85 | 86 | All notable changes to this project will be documented in this file. 87 | 88 | EOF 89 | fi 90 | 91 | # Get commits since last tag or all commits if no tags 92 | last_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") 93 | if [ -z "$last_tag" ]; then 94 | commits=$(git log --oneline --no-merges) 95 | else 96 | commits=$(git log $last_tag..HEAD --oneline --no-merges) 97 | fi 98 | 99 | # Create temp file with new entry 100 | cat > temp_changelog.md << EOF 101 | # Changelog 102 | 103 | All notable changes to this project will be documented in this file. 104 | 105 | ## [${{ steps.version.outputs.new_version }}] - $(date +%Y-%m-%d) 106 | 107 | ### Changes 108 | $commits 109 | 110 | EOF 111 | 112 | # Append rest of changelog if it exists 113 | if [ -f CHANGELOG.md ]; then 114 | tail -n +5 CHANGELOG.md >> temp_changelog.md 115 | fi 116 | 117 | mv temp_changelog.md CHANGELOG.md 118 | 119 | - name: Commit and tag 120 | run: | 121 | git add setup.py pyproject.toml cfm_package/__init__.py CHANGELOG.md 122 | git commit -m "chore: release v${{ steps.version.outputs.new_version }}" 123 | git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}" 124 | 125 | - name: Push changes 126 | run: | 127 | git push origin HEAD:${{ github.ref_name }} 128 | git push origin ${{ steps.version.outputs.tag }} 129 | 130 | - name: Create Release 131 | uses: actions/create-release@v1 132 | env: 133 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 134 | with: 135 | tag_name: ${{ steps.version.outputs.tag }} 136 | release_name: Release ${{ steps.version.outputs.tag }} 137 | body: | 138 | ## Changes in v${{ steps.version.outputs.new_version }} 139 | 140 | See [CHANGELOG.md](./CHANGELOG.md) for detailed changes. 141 | 142 | ### Artifacts 143 | - Python package built and ready for PyPI 144 | draft: false 145 | prerelease: false 146 | 147 | - name: Upload Release Assets 148 | if: hashFiles('dist/*.whl') != '' || hashFiles('dist/*.tar.gz') != '' 149 | run: | 150 | for file in dist/*; do 151 | if [[ -f "$file" ]]; then 152 | gh release upload ${{ steps.version.outputs.tag }} "$file" 153 | fi 154 | done 155 | env: 156 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /auto_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Auto-version script that updates version based on git commit messages. 4 | Uses conventional commit format: feat:, fix:, docs:, style:, refactor:, test:, chore: 5 | 6 | Version bump rules: 7 | - feat: minor version bump (0.x.0) 8 | - fix: patch version bump (0.0.x) 9 | - BREAKING CHANGE in commit body: major version bump (x.0.0) 10 | - Others: no version bump 11 | """ 12 | 13 | import re 14 | import subprocess 15 | import sys 16 | from pathlib import Path 17 | 18 | 19 | def get_current_version(): 20 | """Get current version from setup.py""" 21 | setup_path = Path(__file__).parent / 'setup.py' 22 | with open(setup_path, 'r') as f: 23 | content = f.read() 24 | match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content) 25 | if match: 26 | return match.group(1) 27 | return "0.0.0" 28 | 29 | 30 | def parse_version(version_str): 31 | """Parse version string into major, minor, patch""" 32 | parts = version_str.split('.') 33 | return int(parts[0]), int(parts[1]), int(parts[2]) 34 | 35 | 36 | def bump_version(version_str, bump_type): 37 | """Bump version based on bump type""" 38 | major, minor, patch = parse_version(version_str) 39 | 40 | if bump_type == 'major': 41 | return f"{major + 1}.0.0" 42 | elif bump_type == 'minor': 43 | return f"{major}.{minor + 1}.0" 44 | elif bump_type == 'patch': 45 | return f"{major}.{minor}.{patch + 1}" 46 | else: 47 | return version_str 48 | 49 | 50 | def get_latest_tag(): 51 | """Get the latest git tag""" 52 | try: 53 | result = subprocess.run(['git', 'describe', '--tags', '--abbrev=0'], 54 | capture_output=True, text=True) 55 | if result.returncode == 0: 56 | return result.stdout.strip() 57 | except: 58 | pass 59 | return None 60 | 61 | 62 | def get_commits_since_tag(tag=None): 63 | """Get all commits since the last tag""" 64 | if tag: 65 | cmd = ['git', 'log', f'{tag}..HEAD', '--pretty=format:%s%n%b%n---'] 66 | else: 67 | cmd = ['git', 'log', '--pretty=format:%s%n%b%n---'] 68 | 69 | result = subprocess.run(cmd, capture_output=True, text=True) 70 | if result.returncode != 0: 71 | return [] 72 | 73 | commits = result.stdout.strip().split('\n---\n') 74 | return [c.strip() for c in commits if c.strip()] 75 | 76 | 77 | def determine_bump_type(commits): 78 | """Determine version bump type based on commits""" 79 | has_feat = False 80 | has_fix = False 81 | has_breaking = False 82 | 83 | for commit in commits: 84 | lines = commit.split('\n') 85 | subject = lines[0] if lines else '' 86 | body = '\n'.join(lines[1:]) if len(lines) > 1 else '' 87 | 88 | # Check for breaking change 89 | if 'BREAKING CHANGE' in body or 'BREAKING-CHANGE' in body: 90 | has_breaking = True 91 | 92 | # Check commit type 93 | if subject.startswith('feat:') or subject.startswith('feat('): 94 | has_feat = True 95 | elif subject.startswith('fix:') or subject.startswith('fix('): 96 | has_fix = True 97 | 98 | if has_breaking: 99 | return 'major' 100 | elif has_feat: 101 | return 'minor' 102 | elif has_fix: 103 | return 'patch' 104 | else: 105 | return None 106 | 107 | 108 | def update_version_files(new_version): 109 | """Update version in all relevant files""" 110 | files_to_update = [ 111 | ('setup.py', r'version\s*=\s*["\'][^"\']+["\']', f'version="{new_version}"'), 112 | ('pyproject.toml', r'version\s*=\s*["\'][^"\']+["\']', f'version = "{new_version}"'), 113 | ('cfm_package/__init__.py', r'__version__\s*=\s*["\'][^"\']+["\']', f'__version__ = "{new_version}"'), 114 | ] 115 | 116 | for file_path, pattern, replacement in files_to_update: 117 | full_path = Path(__file__).parent / file_path 118 | if full_path.exists(): 119 | with open(full_path, 'r') as f: 120 | content = f.read() 121 | 122 | updated_content = re.sub(pattern, replacement, content) 123 | 124 | with open(full_path, 'w') as f: 125 | f.write(updated_content) 126 | print(f"Updated {file_path} with version {new_version}") 127 | else: 128 | print(f"Warning: {file_path} not found") 129 | 130 | 131 | def main(): 132 | """Main function""" 133 | # Get current version 134 | current_version = get_current_version() 135 | print(f"Current version: {current_version}") 136 | 137 | # Get latest tag 138 | latest_tag = get_latest_tag() 139 | print(f"Latest tag: {latest_tag or 'None'}") 140 | 141 | # Get commits since last tag 142 | commits = get_commits_since_tag(latest_tag) 143 | if not commits: 144 | print("No commits found since last tag") 145 | return 146 | 147 | print(f"Found {len(commits)} commits since last tag") 148 | 149 | # Determine bump type 150 | bump_type = determine_bump_type(commits) 151 | if not bump_type: 152 | print("No version bump needed (no feat: or fix: commits)") 153 | return 154 | 155 | print(f"Bump type: {bump_type}") 156 | 157 | # Calculate new version 158 | new_version = bump_version(current_version, bump_type) 159 | print(f"New version: {new_version}") 160 | 161 | # Update all version files 162 | update_version_files(new_version) 163 | 164 | # Optionally create git tag 165 | if '--tag' in sys.argv: 166 | subprocess.run(['git', 'add', 'setup.py', 'pyproject.toml', 'cfm_package/__init__.py']) 167 | subprocess.run(['git', 'commit', '-m', f'chore: bump version to {new_version}']) 168 | subprocess.run(['git', 'tag', f'v{new_version}']) 169 | print(f"Created git tag v{new_version}") 170 | 171 | 172 | if __name__ == '__main__': 173 | main() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Context File Manager (CFM) 2 | 3 | A command-line tool for managing shared context files across projects. CFM provides a centralized repository for storing, organizing, and retrieving commonly used files with descriptions and tags. 4 | 5 | ## Features 6 | 7 | - **Centralized Storage**: Store commonly used files and folders in a single repository (~/.context-files by default) 8 | - **File Organization**: Add descriptions and tags to files and folders for easy searching and filtering 9 | - **Folder Support**: Add entire folders with preserved directory structure 10 | - **Quick Retrieval**: Copy files or folders from the repository to any project location 11 | - **Search Capabilities**: Find files and folders by name, description, or tags 12 | - **Multiple Output Formats**: View file listings in table, JSON, or simple format 13 | 14 | ## Installation 15 | 16 | ### From PyPI (Recommended) 17 | 18 | ```bash 19 | # Basic installation 20 | pip install context-file-manager 21 | 22 | # With MCP server support (for AI assistants like Claude) 23 | pip install context-file-manager[mcp] 24 | ``` 25 | 26 | ### From Source 27 | 28 | Clone the repository and install in development mode: 29 | 30 | ```bash 31 | git clone https://github.com/ananddtyagi/context-file-manager.git 32 | cd context-file-manager 33 | pip install -e . 34 | ``` 35 | 36 | ### Manual Installation 37 | 38 | Make the script executable and add it to your PATH: 39 | 40 | ```bash 41 | chmod +x cfm 42 | sudo cp cfm /usr/local/bin/ 43 | ``` 44 | 45 | Or create an alias in your shell configuration: 46 | 47 | ```bash 48 | alias cfm='python3 /path/to/context-file-manager/cfm' 49 | ``` 50 | 51 | ## Usage 52 | 53 | ### Add files and folders to the repository 54 | 55 | ```bash 56 | # Add a file with description 57 | cfm add README.md "Main project documentation" 58 | 59 | # Add a file with description and tags 60 | cfm add config.json "Database configuration" --tags database config production 61 | 62 | # Add a folder with all its contents 63 | cfm add-folder ./src "Source code directory" --tags code javascript 64 | 65 | # Add a folder with tags 66 | cfm add-folder ./templates "Project templates" --tags templates starter 67 | ``` 68 | 69 | ### List files and folders 70 | 71 | ```bash 72 | # List all files and folders 73 | cfm list 74 | 75 | # Filter by tag 76 | cfm list --tag database 77 | 78 | # Output as JSON 79 | cfm list --format json 80 | 81 | # List contents of a specific folder 82 | cfm list-folder src 83 | ``` 84 | 85 | ### Search for files and folders 86 | 87 | ```bash 88 | # Search by filename, description, or tags 89 | cfm search "config" 90 | cfm search "database" 91 | cfm search "template" 92 | ``` 93 | 94 | ### Retrieve files and folders 95 | 96 | ```bash 97 | # Copy a file to current directory 98 | cfm get README.md 99 | 100 | # Copy a file to specific location 101 | cfm get config.json ./my-project/ 102 | 103 | # Copy a folder to current directory 104 | cfm get-folder src 105 | 106 | # Copy a folder to specific location 107 | cfm get-folder templates ./new-project/ 108 | ``` 109 | 110 | ### Update file metadata 111 | 112 | ```bash 113 | # Update description 114 | cfm update config.json "Production database configuration" 115 | 116 | # Add tags to existing file 117 | cfm tag config.json staging development 118 | ``` 119 | 120 | ### Remove files and folders 121 | 122 | ```bash 123 | # Remove a file 124 | cfm remove old-config.json 125 | 126 | # Remove a folder 127 | cfm remove-folder old-src 128 | ``` 129 | 130 | ## Custom Repository Location 131 | 132 | By default, files are stored in `~/.context-files`. You can use a different location: 133 | 134 | ```bash 135 | cfm --repo /path/to/my/repo add file.txt "Description" 136 | ``` 137 | 138 | ## File Storage 139 | 140 | Files are stored with their original names in the repository directory. If a filename already exists, a numbered suffix is added (e.g., `config_1.json`, `config_2.json`). 141 | 142 | Metadata is stored in `spec.json` within the repository, containing: 143 | - File descriptions 144 | - Original file paths 145 | - Tags 146 | - File sizes 147 | - Date added 148 | 149 | ## Examples 150 | 151 | ### Managing Configuration Files 152 | 153 | ```bash 154 | # Store various config files 155 | cfm add nginx.conf "Nginx configuration for load balancing" --tags nginx webserver 156 | cfm add docker-compose.yml "Standard Docker setup" --tags docker devops 157 | cfm add .eslintrc.js "JavaScript linting rules" --tags javascript linting 158 | 159 | # Store entire configuration directories 160 | cfm add-folder ./configs "All configuration files" --tags config settings 161 | cfm add-folder ./docker-configs "Docker configurations" --tags docker devops 162 | 163 | # Find all Docker-related files 164 | cfm list --tag docker 165 | 166 | # Get a config for a new project 167 | cfm get docker-compose.yml ./new-project/ 168 | 169 | # Get entire config folder 170 | cfm get-folder configs ./new-project/ 171 | ``` 172 | 173 | ### Managing Documentation Templates 174 | 175 | ```bash 176 | # Store documentation templates 177 | cfm add README-template.md "Standard README template" --tags documentation template 178 | cfm add API-docs-template.md "API documentation template" --tags documentation api 179 | 180 | # Store documentation folder 181 | cfm add-folder ./doc-templates "Documentation templates" --tags documentation templates 182 | 183 | # Search for documentation 184 | cfm search "template" 185 | 186 | # List contents of documentation folder 187 | cfm list-folder doc-templates 188 | ``` 189 | 190 | ### Managing Project Templates 191 | 192 | ```bash 193 | # Store entire project template structures 194 | cfm add-folder ./react-template "React project template" --tags react javascript template 195 | cfm add-folder ./python-template "Python project template" --tags python template 196 | 197 | # List all templates 198 | cfm list --tag template 199 | 200 | # Create new project from template 201 | cfm get-folder react-template ./my-new-react-app 202 | ``` 203 | 204 | ## MCP Server for AI Assistants 205 | 206 | CFM includes an optional Model Context Protocol (MCP) server that allows AI assistants like Claude to manage your context files. 207 | 208 | ### Installation 209 | 210 | ```bash 211 | pip install context-file-manager[mcp] 212 | ``` 213 | 214 | ### Usage with Claude Desktop 215 | 216 | Add to your Claude Desktop configuration: 217 | 218 | ```json 219 | { 220 | "mcpServers": { 221 | "context-file-manager": { 222 | "command": "cfm-mcp" 223 | } 224 | } 225 | } 226 | ``` 227 | 228 | ### Available MCP Tools 229 | 230 | Once connected, you can use natural language with your AI assistant: 231 | 232 | - **"Store this config file in my context repository"** - Add files with descriptions 233 | - **"Find all files tagged with 'docker'"** - Search and filter files 234 | - **"Retrieve the nginx config for my new project"** - Get files for current work 235 | - **"Add this entire components folder to my repository"** - Store complete directories 236 | - **"List all my database configurations"** - Browse repository contents 237 | 238 | ### MCP Tool Features 239 | 240 | - **File Management**: Add, get, remove, update files and folders 241 | - **Search & Discovery**: List, search, and filter by tags 242 | - **Metadata Management**: Update descriptions and add tags 243 | - **Repository Control**: Custom repository paths supported 244 | 245 | ## PyPI Upload 246 | 247 | To upload a new version to PyPI: 248 | 249 | ```bash 250 | # Test upload 251 | ./upload_to_pypi.sh test 252 | 253 | # Production upload 254 | ./upload_to_pypi.sh prod 255 | ``` 256 | 257 | ## License 258 | 259 | MIT -------------------------------------------------------------------------------- /cfm_package/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | CLI interface for Context File Manager 4 | """ 5 | 6 | import sys 7 | import argparse 8 | import json 9 | import subprocess 10 | import shutil 11 | from .main import ContextFileManager 12 | 13 | 14 | def setup_mcp_config(): 15 | """Generate Claude Desktop MCP configuration for context-file-manager""" 16 | 17 | # Check if MCP dependencies are available 18 | try: 19 | import mcp 20 | except ImportError: 21 | print("❌ MCP dependencies not found!") 22 | print("📦 Please install with MCP support:") 23 | print(" pip install 'context-file-manager[mcp]'") 24 | return 25 | 26 | # Find Python executable 27 | python_path = shutil.which('python') 28 | if not python_path: 29 | python_path = shutil.which('python3') 30 | 31 | if not python_path: 32 | print("❌ Python executable not found in PATH") 33 | return 34 | 35 | # Test if the MCP server module is available 36 | try: 37 | result = subprocess.run([ 38 | python_path, '-c', 'import cfm_package.cfm_mcp_server' 39 | ], capture_output=True, text=True) 40 | 41 | if result.returncode != 0: 42 | print("❌ cfm_package.cfm_mcp_server module not found") 43 | print("📦 Please install with MCP support:") 44 | print(" pip install 'context-file-manager[mcp]'") 45 | return 46 | except Exception as e: 47 | print(f"❌ Error checking MCP server: {e}") 48 | return 49 | 50 | # Generate configuration 51 | config = { 52 | "context-file-manager": { 53 | "command": python_path, 54 | "args": ["-m", "cfm_package.cfm_mcp_server"] 55 | } 56 | } 57 | 58 | print("✅ MCP server configuration ready!") 59 | print("\n📋 Add this to your Claude Desktop config:") 60 | print("=" * 50) 61 | print(json.dumps(config, indent=2)) 62 | print("=" * 50) 63 | print("\n📍 Config file locations:") 64 | print(" macOS: ~/Library/Application Support/Claude/claude_desktop_config.json") 65 | print(" Windows: %APPDATA%\\Claude\\claude_desktop_config.json") 66 | print(" Linux: ~/.config/claude/claude_desktop_config.json") 67 | 68 | 69 | def main(): 70 | parser = argparse.ArgumentParser( 71 | description="Context File Manager - Manage shared context files across projects", 72 | formatter_class=argparse.RawDescriptionHelpFormatter, 73 | epilog=""" 74 | Examples: 75 | # Add a file with description 76 | cfm add README.md "Main project documentation" 77 | 78 | # Add a file with tags 79 | cfm add config.json "Database configuration" --tags database config 80 | 81 | # Add a folder with all its contents 82 | cfm add-folder ./src "Source code directory" --tags code javascript 83 | 84 | # List all files and folders 85 | cfm list 86 | 87 | # List contents of a specific folder 88 | cfm list-folder src 89 | 90 | # Search for files and folders 91 | cfm search "config" 92 | 93 | # Get a file from the repository 94 | cfm get README.md ./my-project/ 95 | 96 | # Get a folder from the repository 97 | cfm get-folder src ./my-project/ 98 | 99 | # Remove a file 100 | cfm remove old-config.json 101 | 102 | # Remove a folder 103 | cfm remove-folder old-src 104 | 105 | # Generate Claude Desktop MCP configuration 106 | cfm setup-mcp 107 | """ 108 | ) 109 | 110 | parser.add_argument('--repo', '-r', help='Repository path (default: ~/.context-files)') 111 | 112 | subparsers = parser.add_subparsers(dest='command', help='Commands') 113 | 114 | # Add command 115 | add_parser = subparsers.add_parser('add', help='Add a file to the repository') 116 | add_parser.add_argument('file', help='Path to the file to add') 117 | add_parser.add_argument('description', help='Description of the file') 118 | add_parser.add_argument('--tags', '-t', nargs='+', help='Tags for the file') 119 | 120 | # Add folder command 121 | add_folder_parser = subparsers.add_parser('add-folder', help='Add a folder to the repository') 122 | add_folder_parser.add_argument('folder', help='Path to the folder to add') 123 | add_folder_parser.add_argument('description', help='Description of the folder') 124 | add_folder_parser.add_argument('--tags', '-t', nargs='+', help='Tags for the folder') 125 | 126 | # List command 127 | list_parser = subparsers.add_parser('list', help='List all files and folders') 128 | list_parser.add_argument('--tag', '-t', help='Filter by tag') 129 | list_parser.add_argument('--format', '-f', choices=['table', 'json', 'simple'], 130 | default='table', help='Output format') 131 | 132 | # Get command 133 | get_parser = subparsers.add_parser('get', help='Get a file from the repository') 134 | get_parser.add_argument('filename', help='Name of the file in the repository') 135 | get_parser.add_argument('destination', nargs='?', help='Destination path (optional)') 136 | 137 | # Get folder command 138 | get_folder_parser = subparsers.add_parser('get-folder', help='Get a folder from the repository') 139 | get_folder_parser.add_argument('folder', help='Name of the folder in the repository') 140 | get_folder_parser.add_argument('destination', nargs='?', help='Destination path (optional)') 141 | 142 | # Remove command 143 | remove_parser = subparsers.add_parser('remove', help='Remove a file from the repository') 144 | remove_parser.add_argument('filename', help='Name of the file to remove') 145 | 146 | # Remove folder command 147 | remove_folder_parser = subparsers.add_parser('remove-folder', help='Remove a folder from the repository') 148 | remove_folder_parser.add_argument('folder', help='Name of the folder to remove') 149 | 150 | # Search command 151 | search_parser = subparsers.add_parser('search', help='Search for files and folders') 152 | search_parser.add_argument('query', help='Search query') 153 | 154 | # Update command 155 | update_parser = subparsers.add_parser('update', help='Update file description') 156 | update_parser.add_argument('filename', help='Name of the file') 157 | update_parser.add_argument('description', help='New description') 158 | 159 | # Tag command 160 | tag_parser = subparsers.add_parser('tag', help='Add tags to a file or folder') 161 | tag_parser.add_argument('filename', help='Name of the file or folder') 162 | tag_parser.add_argument('tags', nargs='+', help='Tags to add') 163 | 164 | # List folder contents command 165 | list_folder_parser = subparsers.add_parser('list-folder', help='List contents of a folder') 166 | list_folder_parser.add_argument('folder', help='Name of the folder to list') 167 | 168 | # Setup MCP command 169 | setup_mcp_parser = subparsers.add_parser('setup-mcp', help='Generate Claude Desktop MCP configuration') 170 | 171 | args = parser.parse_args() 172 | 173 | if not args.command: 174 | parser.print_help() 175 | return 176 | 177 | try: 178 | manager = ContextFileManager(args.repo) 179 | 180 | if args.command == 'add': 181 | manager.add_file(args.file, args.description, args.tags) 182 | elif args.command == 'add-folder': 183 | manager.add_folder(args.folder, args.description, args.tags) 184 | elif args.command == 'list': 185 | manager.list_files(args.tag, args.format) 186 | elif args.command == 'get': 187 | manager.get_file(args.filename, args.destination) 188 | elif args.command == 'get-folder': 189 | manager.get_folder(args.folder, args.destination) 190 | elif args.command == 'remove': 191 | manager.remove_file(args.filename) 192 | elif args.command == 'remove-folder': 193 | manager.remove_folder(args.folder) 194 | elif args.command == 'search': 195 | manager.search_files(args.query) 196 | elif args.command == 'update': 197 | manager.update_description(args.filename, args.description) 198 | elif args.command == 'tag': 199 | manager.add_tags(args.filename, args.tags) 200 | elif args.command == 'list-folder': 201 | manager.list_folder_contents(args.folder) 202 | elif args.command == 'setup-mcp': 203 | setup_mcp_config() 204 | 205 | except Exception as e: 206 | print(f"Error: {e}", file=sys.stderr) 207 | sys.exit(1) 208 | 209 | 210 | if __name__ == "__main__": 211 | main() -------------------------------------------------------------------------------- /cfm_package/cfm_mcp_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from typing import Optional 4 | import sys 5 | 6 | from .main import ContextFileManager 7 | 8 | try: 9 | from mcp.server.fastmcp import FastMCP 10 | except ImportError: 11 | print("Error: MCP server dependencies not installed.", file=sys.stderr) 12 | print("Please install with: pip install context-file-manager[mcp]", file=sys.stderr) 13 | sys.exit(1) 14 | 15 | # Initialize the FastMCP server 16 | mcp = FastMCP("Context File Manager") 17 | 18 | @mcp.tool() 19 | async def cfm_add_file(file_path: str, description: str, tags: Optional[list[str]] = None, repo_path: Optional[str] = None) -> str: 20 | """Add a file to the context repository with description and optional tags. 21 | 22 | Args: 23 | file_path: Path to the file to add 24 | description: Description of the file 25 | tags: Optional list of tags for the file 26 | repo_path: Optional custom repository path 27 | """ 28 | try: 29 | cfm = ContextFileManager(repo_path) 30 | result = cfm.add_file(file_path, description, tags or []) 31 | return str(result) 32 | except Exception as e: 33 | return f"Error: {str(e)}" 34 | 35 | @mcp.tool() 36 | async def cfm_add_folder(folder_path: str, description: str, tags: Optional[list[str]] = None, repo_path: Optional[str] = None) -> str: 37 | """Add a folder to the context repository with description and optional tags. 38 | 39 | Args: 40 | folder_path: Path to the folder to add 41 | description: Description of the folder 42 | tags: Optional list of tags for the folder 43 | repo_path: Optional custom repository path 44 | """ 45 | try: 46 | cfm = ContextFileManager(repo_path) 47 | result = cfm.add_folder(folder_path, description, tags or []) 48 | return str(result) 49 | except Exception as e: 50 | return f"Error: {str(e)}" 51 | 52 | @mcp.tool() 53 | async def cfm_list(tag: Optional[str] = None, format: str = "table", repo_path: Optional[str] = None) -> str: 54 | """List all files and folders in the repository. 55 | 56 | Args: 57 | tag: Optional tag filter 58 | format: Output format (table, json, simple) 59 | repo_path: Optional custom repository path 60 | """ 61 | try: 62 | cfm = ContextFileManager(repo_path) 63 | spec = cfm._load_spec() 64 | 65 | if not spec: 66 | return "The context file repository is currently empty. There are no files or folders added yet." 67 | 68 | # Filter by tag if provided 69 | if tag: 70 | spec = {k: v for k, v in spec.items() if tag in v.get("tags", [])} 71 | if not spec: 72 | return f"No files found with tag: {tag}" 73 | 74 | if format == "table": 75 | return cfm._format_table_output(spec) 76 | elif format == "json": 77 | import json 78 | return json.dumps(spec, indent=2) 79 | elif format == "simple": 80 | return "\n".join(spec.keys()) 81 | 82 | except Exception as e: 83 | return f"Error: {str(e)}" 84 | 85 | @mcp.tool() 86 | async def cfm_search(query: str, repo_path: Optional[str] = None) -> str: 87 | """Search for files and folders by name, description, or tags. 88 | 89 | Args: 90 | query: Search query 91 | repo_path: Optional custom repository path 92 | """ 93 | try: 94 | cfm = ContextFileManager(repo_path) 95 | result = cfm.search_files(query) 96 | return str(result) 97 | except Exception as e: 98 | return f"Error: {str(e)}" 99 | 100 | @mcp.tool() 101 | async def cfm_get_file(filename: str, destination: Optional[str] = None, repo_path: Optional[str] = None) -> str: 102 | """Copy a file from the repository to a destination. 103 | 104 | Args: 105 | filename: Name of the file to retrieve 106 | destination: Destination path (optional, defaults to current directory) 107 | repo_path: Optional custom repository path 108 | """ 109 | try: 110 | cfm = ContextFileManager(repo_path) 111 | result = cfm.get_file(filename, destination) 112 | return str(result) 113 | except Exception as e: 114 | return f"Error: {str(e)}" 115 | 116 | @mcp.tool() 117 | async def cfm_get_folder(folder_name: str, destination: Optional[str] = None, repo_path: Optional[str] = None) -> str: 118 | """Copy a folder from the repository to a destination. 119 | 120 | Args: 121 | folder_name: Name of the folder to retrieve 122 | destination: Destination path (optional, defaults to current directory) 123 | repo_path: Optional custom repository path 124 | """ 125 | try: 126 | cfm = ContextFileManager(repo_path) 127 | result = cfm.get_folder(folder_name, destination) 128 | return str(result) 129 | except Exception as e: 130 | return f"Error: {str(e)}" 131 | 132 | @mcp.tool() 133 | async def cfm_remove(name: str, repo_path: Optional[str] = None) -> str: 134 | """Remove a file from the repository. 135 | 136 | Args: 137 | name: Name of the file to remove 138 | repo_path: Optional custom repository path 139 | """ 140 | try: 141 | cfm = ContextFileManager(repo_path) 142 | result = cfm.remove_file(name) 143 | return str(result) 144 | except Exception as e: 145 | return f"Error: {str(e)}" 146 | 147 | @mcp.tool() 148 | async def cfm_remove_folder(folder_name: str, repo_path: Optional[str] = None) -> str: 149 | """Remove a folder from the repository. 150 | 151 | Args: 152 | folder_name: Name of the folder to remove 153 | repo_path: Optional custom repository path 154 | """ 155 | try: 156 | cfm = ContextFileManager(repo_path) 157 | result = cfm.remove_folder(folder_name) 158 | return str(result) 159 | except Exception as e: 160 | return f"Error: {str(e)}" 161 | 162 | @mcp.tool() 163 | async def cfm_update(filename: str, description: str, repo_path: Optional[str] = None) -> str: 164 | """Update the description of a file. 165 | 166 | Args: 167 | filename: Name of the file to update 168 | description: New description for the file 169 | repo_path: Optional custom repository path 170 | """ 171 | try: 172 | cfm = ContextFileManager(repo_path) 173 | result = cfm.update_description(filename, description) 174 | return str(result) 175 | except Exception as e: 176 | return f"Error: {str(e)}" 177 | 178 | @mcp.tool() 179 | async def cfm_tag(name: str, tags: list[str], repo_path: Optional[str] = None) -> str: 180 | """Add tags to an existing file or folder. 181 | 182 | Args: 183 | name: Name of the file or folder 184 | tags: List of tags to add 185 | repo_path: Optional custom repository path 186 | """ 187 | try: 188 | cfm = ContextFileManager(repo_path) 189 | result = cfm.add_tags(name, tags) 190 | return str(result) 191 | except Exception as e: 192 | return f"Error: {str(e)}" 193 | 194 | @mcp.tool() 195 | async def cfm_status(repo_path: Optional[str] = None) -> str: 196 | """Get repository status including path and basic info. 197 | 198 | Args: 199 | repo_path: Optional custom repository path 200 | """ 201 | try: 202 | cfm = ContextFileManager(repo_path) 203 | spec = cfm._load_spec() 204 | 205 | status_lines = [] 206 | status_lines.append(f"Repository path: {cfm.repo_path}") 207 | status_lines.append(f"Spec file exists: {cfm.spec_file.exists()}") 208 | status_lines.append(f"Repository directory exists: {cfm.repo_path.exists()}") 209 | 210 | if cfm.spec_file.exists(): 211 | file_count = len(spec) 212 | folder_count = sum(1 for info in spec.values() if info.get('type') == 'folder') 213 | regular_file_count = file_count - folder_count 214 | 215 | status_lines.append(f"Total items: {file_count}") 216 | status_lines.append(f"Files: {regular_file_count}") 217 | status_lines.append(f"Folders: {folder_count}") 218 | else: 219 | status_lines.append("No spec file found - repository appears empty") 220 | 221 | return "\n".join(status_lines) 222 | 223 | except Exception as e: 224 | return f"Error: {str(e)}" 225 | 226 | @mcp.tool() 227 | async def cfm_list_folder(folder_name: str, repo_path: Optional[str] = None) -> str: 228 | """List contents of a specific folder in the repository. 229 | 230 | Args: 231 | folder_name: Name of the folder to list 232 | repo_path: Optional custom repository path 233 | """ 234 | try: 235 | cfm = ContextFileManager(repo_path) 236 | result = cfm.list_folder_contents(folder_name) 237 | return str(result) 238 | except Exception as e: 239 | return f"Error: {str(e)}" 240 | 241 | def main(): 242 | """Main entry point for the MCP server.""" 243 | mcp.run() 244 | 245 | if __name__ == "__main__": 246 | main() -------------------------------------------------------------------------------- /cfm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Context File Manager - A CLI tool for managing shared context files across projects 4 | """ 5 | 6 | import os 7 | import sys 8 | import json 9 | import shutil 10 | import argparse 11 | from pathlib import Path 12 | from datetime import datetime 13 | from typing import Dict, List, Optional 14 | 15 | class ContextFileManager: 16 | def __init__(self, repo_path: Optional[str] = None): 17 | """Initialize the file manager with a repository path.""" 18 | if repo_path: 19 | self.repo_path = Path(repo_path).expanduser().resolve() 20 | else: 21 | # Default to ~/.context-files 22 | self.repo_path = Path.home() / ".context-files" 23 | 24 | self.spec_file = self.repo_path / "spec.json" 25 | self._ensure_repo_exists() 26 | 27 | def _ensure_repo_exists(self): 28 | """Create the repository directory and spec file if they don't exist.""" 29 | self.repo_path.mkdir(parents=True, exist_ok=True) 30 | if not self.spec_file.exists(): 31 | self._save_spec({}) 32 | 33 | def _load_spec(self) -> Dict: 34 | """Load the spec.json file.""" 35 | try: 36 | with open(self.spec_file, 'r') as f: 37 | return json.load(f) 38 | except (json.JSONDecodeError, FileNotFoundError): 39 | return {} 40 | 41 | def _save_spec(self, spec: Dict): 42 | """Save the spec.json file.""" 43 | with open(self.spec_file, 'w') as f: 44 | json.dump(spec, f, indent=2) 45 | 46 | def add_file(self, file_path: str, description: str, tags: Optional[List[str]] = None): 47 | """Add a file to the repository.""" 48 | source_path = Path(file_path).expanduser().resolve() 49 | 50 | if not source_path.exists(): 51 | raise FileNotFoundError(f"File not found: {file_path}") 52 | 53 | # Generate a unique filename if needed 54 | dest_filename = source_path.name 55 | dest_path = self.repo_path / dest_filename 56 | 57 | # Handle duplicate filenames 58 | counter = 1 59 | while dest_path.exists(): 60 | stem = source_path.stem 61 | suffix = source_path.suffix 62 | dest_filename = f"{stem}_{counter}{suffix}" 63 | dest_path = self.repo_path / dest_filename 64 | counter += 1 65 | 66 | # Copy the file 67 | shutil.copy2(source_path, dest_path) 68 | 69 | # Update spec 70 | spec = self._load_spec() 71 | spec[dest_filename] = { 72 | "description": description, 73 | "original_path": str(source_path), 74 | "added_date": datetime.now().isoformat(), 75 | "size": source_path.stat().st_size, 76 | "tags": tags or [] 77 | } 78 | self._save_spec(spec) 79 | 80 | print(f"✓ Added: {dest_filename}") 81 | return dest_filename 82 | 83 | def list_files(self, tag: Optional[str] = None, format: str = "table"): 84 | """List all files in the repository.""" 85 | spec = self._load_spec() 86 | 87 | if not spec: 88 | print("No files in repository.") 89 | return 90 | 91 | # Filter by tag if provided 92 | if tag: 93 | spec = {k: v for k, v in spec.items() if tag in v.get("tags", [])} 94 | if not spec: 95 | print(f"No files found with tag: {tag}") 96 | return 97 | 98 | if format == "table": 99 | self._print_table(spec) 100 | elif format == "json": 101 | print(json.dumps(spec, indent=2)) 102 | elif format == "simple": 103 | for filename in spec: 104 | print(filename) 105 | 106 | def _print_table(self, spec: Dict): 107 | """Print files in a formatted table.""" 108 | # Calculate column widths 109 | max_filename = max(len(f) for f in spec.keys()) if spec else 8 110 | max_filename = max(max_filename, 8) # Minimum width 111 | 112 | # Print header 113 | print(f"\n{'Filename':<{max_filename}} | {'Description':<50} | {'Tags':<20} | Size") 114 | print("-" * (max_filename + 50 + 20 + 15)) 115 | 116 | # Print files 117 | for filename, info in spec.items(): 118 | desc = info['description'][:47] + "..." if len(info['description']) > 50 else info['description'] 119 | tags = ", ".join(info.get('tags', []))[:17] + "..." if len(", ".join(info.get('tags', []))) > 20 else ", ".join(info.get('tags', [])) 120 | size = self._format_size(info.get('size', 0)) 121 | print(f"{filename:<{max_filename}} | {desc:<50} | {tags:<20} | {size}") 122 | 123 | def _format_size(self, size: int) -> str: 124 | """Format file size in human-readable format.""" 125 | for unit in ['B', 'KB', 'MB', 'GB']: 126 | if size < 1024.0: 127 | return f"{size:.1f} {unit}" 128 | size /= 1024.0 129 | return f"{size:.1f} TB" 130 | 131 | def get_file(self, filename: str, destination: Optional[str] = None): 132 | """Copy a file from the repository to a destination.""" 133 | source_path = self.repo_path / filename 134 | 135 | if not source_path.exists(): 136 | raise FileNotFoundError(f"File not found in repository: {filename}") 137 | 138 | if destination: 139 | dest_path = Path(destination).expanduser().resolve() 140 | else: 141 | dest_path = Path.cwd() / filename 142 | 143 | shutil.copy2(source_path, dest_path) 144 | print(f"✓ Copied {filename} to {dest_path}") 145 | return str(dest_path) 146 | 147 | def remove_file(self, filename: str): 148 | """Remove a file from the repository.""" 149 | file_path = self.repo_path / filename 150 | 151 | if not file_path.exists(): 152 | raise FileNotFoundError(f"File not found in repository: {filename}") 153 | 154 | # Remove from filesystem 155 | file_path.unlink() 156 | 157 | # Update spec 158 | spec = self._load_spec() 159 | if filename in spec: 160 | del spec[filename] 161 | self._save_spec(spec) 162 | 163 | print(f"✓ Removed: {filename}") 164 | 165 | def search_files(self, query: str): 166 | """Search for files by description or filename.""" 167 | spec = self._load_spec() 168 | query_lower = query.lower() 169 | 170 | matches = {} 171 | for filename, info in spec.items(): 172 | if (query_lower in filename.lower() or 173 | query_lower in info['description'].lower() or 174 | any(query_lower in tag.lower() for tag in info.get('tags', []))): 175 | matches[filename] = info 176 | 177 | if matches: 178 | self._print_table(matches) 179 | else: 180 | print(f"No files found matching: {query}") 181 | 182 | def update_description(self, filename: str, description: str): 183 | """Update the description of a file.""" 184 | spec = self._load_spec() 185 | 186 | if filename not in spec: 187 | raise FileNotFoundError(f"File not found in repository: {filename}") 188 | 189 | spec[filename]['description'] = description 190 | self._save_spec(spec) 191 | print(f"✓ Updated description for: {filename}") 192 | 193 | def add_tags(self, filename: str, tags: List[str]): 194 | """Add tags to a file.""" 195 | spec = self._load_spec() 196 | 197 | if filename not in spec: 198 | raise FileNotFoundError(f"File not found in repository: {filename}") 199 | 200 | current_tags = set(spec[filename].get('tags', [])) 201 | current_tags.update(tags) 202 | spec[filename]['tags'] = list(current_tags) 203 | self._save_spec(spec) 204 | print(f"✓ Added tags to {filename}: {', '.join(tags)}") 205 | 206 | def main(): 207 | parser = argparse.ArgumentParser( 208 | description="Context File Manager - Manage shared context files across projects", 209 | formatter_class=argparse.RawDescriptionHelpFormatter, 210 | epilog=""" 211 | Examples: 212 | # Add a file with description 213 | cfm add README.md "Main project documentation" 214 | 215 | # Add a file with tags 216 | cfm add config.json "Database configuration" --tags database config 217 | 218 | # List all files 219 | cfm list 220 | 221 | # Search for files 222 | cfm search "config" 223 | 224 | # Get a file from the repository 225 | cfm get README.md ./my-project/ 226 | 227 | # Remove a file 228 | cfm remove old-config.json 229 | """ 230 | ) 231 | 232 | parser.add_argument('--repo', '-r', help='Repository path (default: ~/.context-files)') 233 | 234 | subparsers = parser.add_subparsers(dest='command', help='Commands') 235 | 236 | # Add command 237 | add_parser = subparsers.add_parser('add', help='Add a file to the repository') 238 | add_parser.add_argument('file', help='Path to the file to add') 239 | add_parser.add_argument('description', help='Description of the file') 240 | add_parser.add_argument('--tags', '-t', nargs='+', help='Tags for the file') 241 | 242 | # List command 243 | list_parser = subparsers.add_parser('list', help='List all files') 244 | list_parser.add_argument('--tag', '-t', help='Filter by tag') 245 | list_parser.add_argument('--format', '-f', choices=['table', 'json', 'simple'], 246 | default='table', help='Output format') 247 | 248 | # Get command 249 | get_parser = subparsers.add_parser('get', help='Get a file from the repository') 250 | get_parser.add_argument('filename', help='Name of the file in the repository') 251 | get_parser.add_argument('destination', nargs='?', help='Destination path (optional)') 252 | 253 | # Remove command 254 | remove_parser = subparsers.add_parser('remove', help='Remove a file from the repository') 255 | remove_parser.add_argument('filename', help='Name of the file to remove') 256 | 257 | # Search command 258 | search_parser = subparsers.add_parser('search', help='Search for files') 259 | search_parser.add_argument('query', help='Search query') 260 | 261 | # Update command 262 | update_parser = subparsers.add_parser('update', help='Update file description') 263 | update_parser.add_argument('filename', help='Name of the file') 264 | update_parser.add_argument('description', help='New description') 265 | 266 | # Tag command 267 | tag_parser = subparsers.add_parser('tag', help='Add tags to a file') 268 | tag_parser.add_argument('filename', help='Name of the file') 269 | tag_parser.add_argument('tags', nargs='+', help='Tags to add') 270 | 271 | args = parser.parse_args() 272 | 273 | if not args.command: 274 | parser.print_help() 275 | return 276 | 277 | try: 278 | manager = ContextFileManager(args.repo) 279 | 280 | if args.command == 'add': 281 | manager.add_file(args.file, args.description, args.tags) 282 | elif args.command == 'list': 283 | manager.list_files(args.tag, args.format) 284 | elif args.command == 'get': 285 | manager.get_file(args.filename, args.destination) 286 | elif args.command == 'remove': 287 | manager.remove_file(args.filename) 288 | elif args.command == 'search': 289 | manager.search_files(args.query) 290 | elif args.command == 'update': 291 | manager.update_description(args.filename, args.description) 292 | elif args.command == 'tag': 293 | manager.add_tags(args.filename, args.tags) 294 | 295 | except Exception as e: 296 | print(f"Error: {e}", file=sys.stderr) 297 | sys.exit(1) 298 | 299 | if __name__ == "__main__": 300 | main() -------------------------------------------------------------------------------- /cfm_package/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Context File Manager - A CLI tool for managing shared context files across projects 3 | """ 4 | 5 | import os 6 | import sys 7 | import json 8 | import shutil 9 | import argparse 10 | from pathlib import Path 11 | from datetime import datetime 12 | from typing import Dict, List, Optional 13 | 14 | class ContextFileManager: 15 | def __init__(self, repo_path: Optional[str] = None): 16 | """Initialize the file manager with a repository path.""" 17 | if repo_path: 18 | self.repo_path = Path(repo_path).expanduser().resolve() 19 | else: 20 | # Default to ~/.context-files 21 | self.repo_path = Path.home() / ".context-files" 22 | 23 | self.spec_file = self.repo_path / "spec.json" 24 | self._ensure_repo_exists() 25 | 26 | def _ensure_repo_exists(self): 27 | """Create the repository directory and spec file if they don't exist.""" 28 | self.repo_path.mkdir(parents=True, exist_ok=True) 29 | if not self.spec_file.exists(): 30 | self._save_spec({}) 31 | 32 | def _load_spec(self) -> Dict: 33 | """Load the spec.json file.""" 34 | try: 35 | with open(self.spec_file, 'r') as f: 36 | return json.load(f) 37 | except (json.JSONDecodeError, FileNotFoundError): 38 | return {} 39 | 40 | def _save_spec(self, spec: Dict): 41 | """Save the spec.json file.""" 42 | with open(self.spec_file, 'w') as f: 43 | json.dump(spec, f, indent=2) 44 | 45 | def add_file(self, file_path: str, description: str, tags: Optional[List[str]] = None): 46 | """Add a file to the repository.""" 47 | source_path = Path(file_path).expanduser().resolve() 48 | 49 | if not source_path.exists(): 50 | raise FileNotFoundError(f"File not found: {file_path}") 51 | 52 | # Generate a unique filename if needed 53 | dest_filename = source_path.name 54 | dest_path = self.repo_path / dest_filename 55 | 56 | # Handle duplicate filenames 57 | counter = 1 58 | while dest_path.exists(): 59 | stem = source_path.stem 60 | suffix = source_path.suffix 61 | dest_filename = f"{stem}_{counter}{suffix}" 62 | dest_path = self.repo_path / dest_filename 63 | counter += 1 64 | 65 | # Copy the file 66 | shutil.copy2(source_path, dest_path) 67 | 68 | # Update spec 69 | spec = self._load_spec() 70 | spec[dest_filename] = { 71 | "description": description, 72 | "original_path": str(source_path), 73 | "added_date": datetime.now().isoformat(), 74 | "size": source_path.stat().st_size, 75 | "tags": tags or [], 76 | "type": "file" 77 | } 78 | self._save_spec(spec) 79 | 80 | print(f"✓ Added: {dest_filename}") 81 | return dest_filename 82 | 83 | def add_folder(self, folder_path: str, description: str, tags: Optional[List[str]] = None): 84 | """Add a folder and all its contents to the repository.""" 85 | source_path = Path(folder_path).expanduser().resolve() 86 | 87 | if not source_path.exists(): 88 | raise FileNotFoundError(f"Folder not found: {folder_path}") 89 | 90 | if not source_path.is_dir(): 91 | raise ValueError(f"Path is not a directory: {folder_path}") 92 | 93 | # Generate unique folder name 94 | folder_name = source_path.name 95 | dest_folder_path = self.repo_path / folder_name 96 | 97 | # Handle duplicate folder names 98 | counter = 1 99 | while dest_folder_path.exists(): 100 | folder_name = f"{source_path.name}_{counter}" 101 | dest_folder_path = self.repo_path / folder_name 102 | counter += 1 103 | 104 | # Create the folder 105 | dest_folder_path.mkdir(parents=True, exist_ok=True) 106 | 107 | # Copy all contents recursively 108 | files_added = [] 109 | total_size = 0 110 | 111 | for item in source_path.rglob("*"): 112 | if item.is_file(): 113 | # Calculate relative path 114 | rel_path = item.relative_to(source_path) 115 | dest_item_path = dest_folder_path / rel_path 116 | 117 | # Create parent directories if needed 118 | dest_item_path.parent.mkdir(parents=True, exist_ok=True) 119 | 120 | # Copy the file 121 | shutil.copy2(item, dest_item_path) 122 | files_added.append(str(rel_path)) 123 | total_size += item.stat().st_size 124 | 125 | # Update spec 126 | spec = self._load_spec() 127 | spec[folder_name] = { 128 | "description": description, 129 | "original_path": str(source_path), 130 | "added_date": datetime.now().isoformat(), 131 | "size": total_size, 132 | "tags": tags or [], 133 | "type": "folder", 134 | "file_count": len(files_added), 135 | "files": files_added 136 | } 137 | self._save_spec(spec) 138 | 139 | print(f"✓ Added folder: {folder_name} ({len(files_added)} files)") 140 | return folder_name 141 | 142 | def list_files(self, tag: Optional[str] = None, format: str = "table"): 143 | """List all files in the repository.""" 144 | spec = self._load_spec() 145 | 146 | if not spec: 147 | print("No files in repository.") 148 | return 149 | 150 | # Filter by tag if provided 151 | if tag: 152 | spec = {k: v for k, v in spec.items() if tag in v.get("tags", [])} 153 | if not spec: 154 | print(f"No files found with tag: {tag}") 155 | return 156 | 157 | if format == "table": 158 | self._print_table(spec) 159 | elif format == "json": 160 | print(json.dumps(spec, indent=2)) 161 | elif format == "simple": 162 | for filename in spec: 163 | print(filename) 164 | 165 | def _print_table(self, spec: Dict): 166 | """Print files in a formatted table.""" 167 | print(self._format_table_output(spec)) 168 | 169 | def _format_table_output(self, spec: Dict) -> str: 170 | """Format files in a table and return as string.""" 171 | # Calculate column widths 172 | max_filename = max(len(f) for f in spec.keys()) if spec else 8 173 | max_filename = max(max_filename, 8) # Minimum width 174 | 175 | lines = [] 176 | # Header 177 | lines.append(f"{'Name':<{max_filename}} | {'Type':<6} | {'Description':<40} | {'Tags':<20} | Size") 178 | lines.append("-" * (max_filename + 6 + 40 + 20 + 20)) 179 | 180 | # Files and folders 181 | for filename, info in spec.items(): 182 | item_type = info.get('type', 'file') 183 | if item_type == 'folder': 184 | type_str = "folder" 185 | size_str = f"{self._format_size(info.get('size', 0))} ({info.get('file_count', 0)} files)" 186 | else: 187 | type_str = "file" 188 | size_str = self._format_size(info.get('size', 0)) 189 | 190 | desc = info['description'][:37] + "..." if len(info['description']) > 40 else info['description'] 191 | tags = ", ".join(info.get('tags', []))[:17] + "..." if len(", ".join(info.get('tags', []))) > 20 else ", ".join(info.get('tags', [])) 192 | 193 | lines.append(f"{filename:<{max_filename}} | {type_str:<6} | {desc:<40} | {tags:<20} | {size_str}") 194 | 195 | return "\n".join(lines) 196 | 197 | def _format_size(self, size: int) -> str: 198 | """Format file size in human-readable format.""" 199 | for unit in ['B', 'KB', 'MB', 'GB']: 200 | if size < 1024.0: 201 | return f"{size:.1f} {unit}" 202 | size /= 1024.0 203 | return f"{size:.1f} TB" 204 | 205 | def get_file(self, filename: str, destination: Optional[str] = None): 206 | """Copy a file from the repository to a destination.""" 207 | source_path = self.repo_path / filename 208 | 209 | if not source_path.exists(): 210 | raise FileNotFoundError(f"File not found in repository: {filename}") 211 | 212 | if destination: 213 | dest_path = Path(destination).expanduser().resolve() 214 | else: 215 | dest_path = Path.cwd() / filename 216 | 217 | shutil.copy2(source_path, dest_path) 218 | print(f"✓ Copied {filename} to {dest_path}") 219 | return str(dest_path) 220 | 221 | def get_folder(self, folder_name: str, destination: Optional[str] = None): 222 | """Copy a folder from the repository to a destination.""" 223 | source_path = self.repo_path / folder_name 224 | 225 | if not source_path.exists(): 226 | raise FileNotFoundError(f"Folder not found in repository: {folder_name}") 227 | 228 | if not source_path.is_dir(): 229 | raise ValueError(f"Not a folder: {folder_name}") 230 | 231 | if destination: 232 | dest_base = Path(destination).expanduser().resolve() 233 | else: 234 | dest_base = Path.cwd() 235 | 236 | dest_path = dest_base / folder_name 237 | 238 | # Check if destination already exists 239 | if dest_path.exists(): 240 | raise FileExistsError(f"Destination already exists: {dest_path}") 241 | 242 | # Copy the entire folder 243 | shutil.copytree(source_path, dest_path) 244 | 245 | # Count files 246 | file_count = sum(1 for _ in dest_path.rglob("*") if _.is_file()) 247 | 248 | print(f"✓ Copied folder {folder_name} to {dest_path} ({file_count} files)") 249 | return str(dest_path) 250 | 251 | def remove_file(self, filename: str): 252 | """Remove a file from the repository.""" 253 | file_path = self.repo_path / filename 254 | 255 | if not file_path.exists(): 256 | raise FileNotFoundError(f"File not found in repository: {filename}") 257 | 258 | # Remove from filesystem 259 | file_path.unlink() 260 | 261 | # Update spec 262 | spec = self._load_spec() 263 | if filename in spec: 264 | del spec[filename] 265 | self._save_spec(spec) 266 | 267 | print(f"✓ Removed: {filename}") 268 | 269 | def remove_folder(self, folder_name: str): 270 | """Remove a folder from the repository.""" 271 | folder_path = self.repo_path / folder_name 272 | 273 | if not folder_path.exists(): 274 | raise FileNotFoundError(f"Folder not found in repository: {folder_name}") 275 | 276 | if not folder_path.is_dir(): 277 | raise ValueError(f"Not a folder: {folder_name}") 278 | 279 | # Remove from filesystem 280 | shutil.rmtree(folder_path) 281 | 282 | # Update spec 283 | spec = self._load_spec() 284 | if folder_name in spec: 285 | del spec[folder_name] 286 | self._save_spec(spec) 287 | 288 | print(f"✓ Removed folder: {folder_name}") 289 | 290 | def search_files(self, query: str): 291 | """Search for files by description or filename.""" 292 | spec = self._load_spec() 293 | query_lower = query.lower() 294 | 295 | matches = {} 296 | for filename, info in spec.items(): 297 | if (query_lower in filename.lower() or 298 | query_lower in info['description'].lower() or 299 | any(query_lower in tag.lower() for tag in info.get('tags', []))): 300 | matches[filename] = info 301 | 302 | if matches: 303 | self._print_table(matches) 304 | else: 305 | print(f"No files found matching: {query}") 306 | 307 | def update_description(self, filename: str, description: str): 308 | """Update the description of a file.""" 309 | spec = self._load_spec() 310 | 311 | if filename not in spec: 312 | raise FileNotFoundError(f"File not found in repository: {filename}") 313 | 314 | spec[filename]['description'] = description 315 | self._save_spec(spec) 316 | print(f"✓ Updated description for: {filename}") 317 | 318 | def add_tags(self, filename: str, tags: List[str]): 319 | """Add tags to a file.""" 320 | spec = self._load_spec() 321 | 322 | if filename not in spec: 323 | raise FileNotFoundError(f"File not found in repository: {filename}") 324 | 325 | current_tags = set(spec[filename].get('tags', [])) 326 | current_tags.update(tags) 327 | spec[filename]['tags'] = list(current_tags) 328 | self._save_spec(spec) 329 | print(f"✓ Added tags to {filename}: {', '.join(tags)}") 330 | 331 | def list_folder_contents(self, folder_name: str): 332 | """List the contents of a folder in the repository.""" 333 | spec = self._load_spec() 334 | 335 | if folder_name not in spec: 336 | raise FileNotFoundError(f"Folder not found in repository: {folder_name}") 337 | 338 | folder_info = spec[folder_name] 339 | if folder_info.get('type') != 'folder': 340 | raise ValueError(f"Not a folder: {folder_name}") 341 | 342 | print(f"\nContents of folder: {folder_name}") 343 | print(f"Description: {folder_info['description']}") 344 | print(f"Total size: {self._format_size(folder_info.get('size', 0))}") 345 | print(f"File count: {folder_info.get('file_count', 0)}") 346 | 347 | if folder_info.get('tags'): 348 | print(f"Tags: {', '.join(folder_info['tags'])}") 349 | 350 | files = folder_info.get('files', []) 351 | if files: 352 | print(f"\nFiles:") 353 | for file_path in sorted(files): 354 | print(f" - {file_path}") 355 | else: 356 | print("\nNo files in folder.") 357 | 358 | def main(): 359 | parser = argparse.ArgumentParser( 360 | description="Context File Manager - Manage shared context files across projects", 361 | formatter_class=argparse.RawDescriptionHelpFormatter, 362 | epilog=""" 363 | Examples: 364 | # Add a file with description 365 | cfm add README.md "Main project documentation" 366 | 367 | # Add a file with tags 368 | cfm add config.json "Database configuration" --tags database config 369 | 370 | # Add a folder with all its contents 371 | cfm add-folder ./src "Source code directory" --tags code javascript 372 | 373 | # List all files and folders 374 | cfm list 375 | 376 | # List contents of a specific folder 377 | cfm list-folder src 378 | 379 | # Search for files and folders 380 | cfm search "config" 381 | 382 | # Get a file from the repository 383 | cfm get README.md ./my-project/ 384 | 385 | # Get a folder from the repository 386 | cfm get-folder src ./my-project/ 387 | 388 | # Remove a file 389 | cfm remove old-config.json 390 | 391 | # Remove a folder 392 | cfm remove-folder old-src 393 | """ 394 | ) 395 | 396 | parser.add_argument('--repo', '-r', help='Repository path (default: ~/.context-files)') 397 | 398 | subparsers = parser.add_subparsers(dest='command', help='Commands') 399 | 400 | # Add command 401 | add_parser = subparsers.add_parser('add', help='Add a file to the repository') 402 | add_parser.add_argument('file', help='Path to the file to add') 403 | add_parser.add_argument('description', help='Description of the file') 404 | add_parser.add_argument('--tags', '-t', nargs='+', help='Tags for the file') 405 | 406 | # Add folder command 407 | add_folder_parser = subparsers.add_parser('add-folder', help='Add a folder to the repository') 408 | add_folder_parser.add_argument('folder', help='Path to the folder to add') 409 | add_folder_parser.add_argument('description', help='Description of the folder') 410 | add_folder_parser.add_argument('--tags', '-t', nargs='+', help='Tags for the folder') 411 | 412 | # List command 413 | list_parser = subparsers.add_parser('list', help='List all files and folders') 414 | list_parser.add_argument('--tag', '-t', help='Filter by tag') 415 | list_parser.add_argument('--format', '-f', choices=['table', 'json', 'simple'], 416 | default='table', help='Output format') 417 | 418 | # Get command 419 | get_parser = subparsers.add_parser('get', help='Get a file from the repository') 420 | get_parser.add_argument('filename', help='Name of the file in the repository') 421 | get_parser.add_argument('destination', nargs='?', help='Destination path (optional)') 422 | 423 | # Get folder command 424 | get_folder_parser = subparsers.add_parser('get-folder', help='Get a folder from the repository') 425 | get_folder_parser.add_argument('folder', help='Name of the folder in the repository') 426 | get_folder_parser.add_argument('destination', nargs='?', help='Destination path (optional)') 427 | 428 | # Remove command 429 | remove_parser = subparsers.add_parser('remove', help='Remove a file from the repository') 430 | remove_parser.add_argument('filename', help='Name of the file to remove') 431 | 432 | # Remove folder command 433 | remove_folder_parser = subparsers.add_parser('remove-folder', help='Remove a folder from the repository') 434 | remove_folder_parser.add_argument('folder', help='Name of the folder to remove') 435 | 436 | # Search command 437 | search_parser = subparsers.add_parser('search', help='Search for files and folders') 438 | search_parser.add_argument('query', help='Search query') 439 | 440 | # Update command 441 | update_parser = subparsers.add_parser('update', help='Update file description') 442 | update_parser.add_argument('filename', help='Name of the file') 443 | update_parser.add_argument('description', help='New description') 444 | 445 | # Tag command 446 | tag_parser = subparsers.add_parser('tag', help='Add tags to a file or folder') 447 | tag_parser.add_argument('filename', help='Name of the file or folder') 448 | tag_parser.add_argument('tags', nargs='+', help='Tags to add') 449 | 450 | # List folder contents command 451 | list_folder_parser = subparsers.add_parser('list-folder', help='List contents of a folder') 452 | list_folder_parser.add_argument('folder', help='Name of the folder to list') 453 | 454 | args = parser.parse_args() 455 | 456 | if not args.command: 457 | parser.print_help() 458 | return 459 | 460 | try: 461 | manager = ContextFileManager(args.repo) 462 | 463 | if args.command == 'add': 464 | manager.add_file(args.file, args.description, args.tags) 465 | elif args.command == 'add-folder': 466 | manager.add_folder(args.folder, args.description, args.tags) 467 | elif args.command == 'list': 468 | manager.list_files(args.tag, args.format) 469 | elif args.command == 'get': 470 | manager.get_file(args.filename, args.destination) 471 | elif args.command == 'get-folder': 472 | manager.get_folder(args.folder, args.destination) 473 | elif args.command == 'remove': 474 | manager.remove_file(args.filename) 475 | elif args.command == 'remove-folder': 476 | manager.remove_folder(args.folder) 477 | elif args.command == 'search': 478 | manager.search_files(args.query) 479 | elif args.command == 'update': 480 | manager.update_description(args.filename, args.description) 481 | elif args.command == 'tag': 482 | manager.add_tags(args.filename, args.tags) 483 | elif args.command == 'list-folder': 484 | manager.list_folder_contents(args.folder) 485 | 486 | except Exception as e: 487 | print(f"Error: {e}", file=sys.stderr) 488 | sys.exit(1) 489 | 490 | if __name__ == "__main__": 491 | main() --------------------------------------------------------------------------------