# スプシ連携スキル — 導入プロンプト（このファイルを丸ごとAIに渡してください）

---

## 【受け取った方へ】使い方（ここだけ読めばOK）

1. **Claude Code**（または Codex）を起動する。
2. このファイルを **AIにドラッグ＆ドロップで添付**（またはファイルを開いて全文コピー → AIに貼り付け）して送信する。
3. AIが自動でスキルを組み立てます。終わったら **AIを完全に終了して再起動**。
4. `/gas-sheets` で起動します。

**ZIPの手動保存・解凍なしで復元できます。** 配布ZIPと同じ 5 ファイルをテキストで収録しています。復元後の依存ツール導入は説明書に従います。OSや環境による確認・警告が出る場合もあります。

うまくいかない時は「スキルが作れているか確認して」とAIに伝えてください。

---

あなた（AI）はこれから、「スプシ連携スキル」を利用者のPCに構築します。以下の指示に厳密に従ってください。

## 手順

0. 実行環境（Mac / Windows）を確認する。**手順1〜3ではファイル復元だけを行う。** 配布ファイルは本文にそろっている。外部ツールの依存導入は手順4で行う。

1. 作業フォルダ `~/Downloads/AI顧問スキル/gas-sheets/` を作成する（Windows は `%USERPROFILE%\Downloads\AI顧問スキル\gas-sheets\`）。
   - 同名フォルダが既にある場合は新しい作業フォルダへ復元する。導入先の旧版を別の場所にバックアップし、mydata・本人ナレッジ・認証設定を保全してから切り替える。復元やセットアップに失敗したら旧版へ戻す。

2. 下の「ファイル定義」にある **5 ファイル** を、指定された相対パス（`~/Downloads/AI顧問スキル/gas-sheets/` からの相対）に作成する。
   - 各ファイルの内容は `========== FILE: パス ==========` 行の次の行から `========== END FILE ==========` 行の直前の行までを、
     **一字一句変更せずそのまま** 書き込む。ファイルの末尾は改行1つで終わらせる。
   - 要約・整形・体裁変更・翻訳・改行の増減・全角半角の変換・Markdown記法の「修正」は一切禁止。
     マーカー行（`========== ... ==========`）自体はファイルに含めない。
   - ファイル数が多い場合は数ファイルずつに分けて作成してよい。**途中で省略せず、必ず全ファイルを作りきる。**

3. 全ファイル作成後、次を検証して結果を報告する:
   - `~/Downloads/AI顧問スキル/gas-sheets/` 配下に 5 ファイルが存在すること
   - 各ファイルの行数（`wc -l`）が下の「行数チェック表」と一致すること。
     一致しないファイルがあれば、そのファイルだけ作り直す。

4. **セットアップまで実行する。** 作成したフォルダの中にある説明書
   （`README.md` / `README_使い方.md` / `INSTALL.md` / `INSTALL_FOR_AI.md` / `はじめにお読みください.md` のうち存在するもの）を読み、
   そこに書かれた手順どおりに、このPCで `/gas-sheets` が使える状態まで導入を完了させる。
   - 説明書に Claude Code 用・Codex 用の両方が書かれている場合は、現在のアプリを確認して該当手順を使う。判別できない場合だけ利用者に聞く。
   - 追加で必要なもの（Python等）が足りなければ、OSを確認して1ステップずつ案内する。

5. 最後に利用者へ、次の3点を伝える:
   - 作成したフォルダの場所（フルパス）
   - **AIを完全に終了して再起動する**必要があること
   - 再起動後 `/gas-sheets` で呼び出せること

## 行数チェック表（wc -l の値）

| ファイル | 行数 |
|---|---|
| SKILL.md | 55 |
| templates/Code.gs | 60 |
| templates/sheets_append.py | 162 |
| templates/スプシ名前帳.md | 37 |
| templates/手順.md | 5 |

---

## ファイル定義

========== FILE: SKILL.md ==========
---
name: gas-sheets
description: Google Apps Scriptの窓口と同梱Pythonを使い、指定スプレッドシートへの行追記を設定する。初期接続、名前帳の登録、追記失敗の切り分けに使う。Claude Code/Codex、Mac/Windows対応。
---

# スプシ連携

AIから指定した表へ行を追記できる状態を作る。既存の表、名前帳、認証情報を保全する。
テンプレートは、このSKILL.mdと同じフォルダの `templates/` を使う。別の利用者の設定や固定ホームパスに依存しない。

## 初めに確認すること

OS、作業フォルダ、初期設定か既存接続の修理かを確認。会話や環境から分かることは聞き直さない。
既存のGAS接続があるなら設定を作り直さず、下の検証工程へ進む。本人が行うブラウザ操作は画面単位で案内し、AIができるファイル準備のたびに返事を求めない。

## 初期設定

1. 利用者の指定先（指定なしならホームのDocuments/スプシ連携）に `templates/` の4ファイルをコピーする。同名ファイルがある場合は差分を確認し、名前帳や設定を上書きしない。
2. `.env` がなければ、`GAS_SHEETS_URL=` と `GAS_SHEETS_TOKEN=` の雛形を作る。TOKENは本人のローカル環境でランダム生成してファイルへ直接保存する。標準出力・チャット・手順.mdには実値を書かない。既存TOKENは保持する。生成にはPythonの `secrets.token_hex(32)` 等を使い、値をprintしない。
3. `.gitignore` に `.env` を追加し、macOS/Linuxでは `.env` を0600にする。ファイルアクセス制限に止められた場合は本人がエディタで設定する。
4. 本人が `https://script.google.com/` で新規プロジェクトを作り、同梱 `Code.gs` を貼り付ける。既存GASに導入する場合は、既存機能を削除せず別プロジェクトにする。
5. プロジェクトの設定→スクリプトプロパティに `TOKEN` を登録する。値は本人が `.env` からコピーする。チャットや画面共有に表示させない。
6. Webアプリとして、実行者＝本人、アクセス＝全員でデプロイする。この方式ではURLへ外部からアクセスでき、合言葉が漏れると本人が編集できる表へ書ける。許可画面のアカウント・プロジェクト・権限を本人が確認して進める。「100%安全」と説明しない。組織ポリシーで使えない場合は設定を迂回しない。
7. `/exec` で終わるURLを本人が `.env` の `GAS_SHEETS_URL` に保存する。`/dev` は使わない。更新時はGASの保存だけでなく、デプロイ管理で新バージョンを選ぶ。

## 検証

同梱 `sheets_append.py` はPython標準ライブラリのみ。`python3` またはWindowsの `python` で実行する。

1. WebアプリURLのGETは死活確認だけ。認証や書き込み成功を意味しない。
2. 本人が指定したテスト用スプレッドシートと既存のタブ名を使い、1行だけテストする。同梱クライアントは既定でプレビューのみ。
3. 送るJSONをUTF-8ファイルに保存して、まず `python3 sheets_append.py --sheet-id <ID> --tab <タブ名> --rows-file <JSONファイル>` で確認する。本人が許可したテストを `--apply` 付きで1回実行する。
4. `ok: true`、追記件数、タブ名を確認し、本人の表でも行を確認する。空の一覧やHTTP 200だけを成功としない。
5. 不正TOKENでは書き込まれないことを、値を表示せずテストする。認証失敗時にデータやタブが増えていないことも確認する。
6. `スプシ名前帳.md` に名前とIDを登録する。同名で別IDがあれば選択を求め、上書きしない。

## 通常の追記

- 対象の名前/ID、タブ、列順、データの意味を確認。列数が違う行は送らない。見出しを渡す場合はデータと同じ列数にする。
- `--apply` がないと送信しない。依頼で対象と本文が確定していれば再確認は不要。
- 存在しないタブは自動で増やさない。作成を頼まれたときだけ `--create-tab` を付ける。
- 先頭が `=` の文字列は既定で文字として保存する。数式を書きたい依頼のときだけ `--allow-formulas` を付ける。
- 結果不明の通信失敗では自動再送しない。表を確認してから未反映分だけ再実行する。この窓口は重複排除を保証しない。

## 切り分け

| 症状 | 確認 |
|---|---|
| unauthorized | TOKENの登録先、空欄、GAS新バージョン。値は表示しない |
| tab_not_found | タブ名の表記。新規作成が必要かを確認 |
| invalid_rows / header_width_mismatch | 行の形・列数・見出しをローカルで直す |
| busy | 同時実行中。未送信と判定できる場合のみ時間を置いて再実行 |
| HTTPエラー/タイムアウト/JSON以外 | `/exec`、公開設定、権限、既存行を確認。連打しない |

公式仕様: [Webアプリ](https://developers.google.com/apps-script/guides/web)、[LockService](https://developers.google.com/apps-script/reference/lock)、[Range.setValues](https://developers.google.com/apps-script/reference/spreadsheet/range#setvaluesvalues)。画面名が変わった場合は現行表示を確認する。
========== END FILE ==========

========== FILE: templates/Code.gs ==========
/** スプシ追記窓口。TOKENはスクリプトプロパティへ保存。 */
function json_(obj) {
  return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(ContentService.MimeType.JSON);
}
function doGet() {
  return json_({ok:true, message:'スプシ連携の窓口は生きています。データ送信はPOSTで。'});
}
function validCell_(v) {
  return v === null || typeof v === 'string' || typeof v === 'boolean' ||
    (typeof v === 'number' && isFinite(v));
}
function doPost(e) {
  var lock, locked = false;
  try {
    if (!e || !e.postData || typeof e.postData.contents !== 'string')
      return json_({ok:false,error:'invalid_request'});
    var body;
    try { body = JSON.parse(e.postData.contents); }
    catch (_) { return json_({ok:false,error:'invalid_json'}); }
    var token = PropertiesService.getScriptProperties().getProperty('TOKEN');
    if (!body || !token || typeof body.token !== 'string' || body.token !== token)
      return json_({ok:false,error:'unauthorized'});
    if (typeof body.sheetId !== 'string' || !/^[A-Za-z0-9_-]+$/.test(body.sheetId))
      return json_({ok:false,error:'invalid_sheet_id'});
    var rows = body.rows;
    if (!Array.isArray(rows) || !rows.length || rows.length > 1000 ||
        !Array.isArray(rows[0]) || !rows[0].length || rows[0].length > 100)
      return json_({ok:false,error:'invalid_rows'});
    var width = rows[0].length;
    if (!rows.every(function(row) {
      return Array.isArray(row) && row.length === width && row.every(validCell_);
    })) return json_({ok:false,error:'invalid_rows'});
    if (body.headers !== undefined && (!Array.isArray(body.headers) ||
        body.headers.length !== width || !body.headers.every(function(v){return typeof v==='string';})))
      return json_({ok:false,error:'header_width_mismatch'});
    if (body.tab !== undefined && (typeof body.tab !== 'string' || !body.tab.trim()))
      return json_({ok:false,error:'invalid_tab'});
    function safeCell(v) {
      if (v === null) return '';
      return typeof v === 'string' && v.charAt(0) === '=' && body.allowFormulas !== true ? "'" + v : v;
    }
    lock = LockService.getScriptLock();
    locked = lock.tryLock(5000);
    if (!locked) return json_({ok:false,error:'busy'});
    var ss = SpreadsheetApp.openById(body.sheetId);
    var sheet = body.tab ? ss.getSheetByName(body.tab) : ss.getSheets()[0];
    if (!sheet && body.createTab === true && body.tab) sheet = ss.insertSheet(body.tab);
    if (!sheet) return json_({ok:false,error:'tab_not_found'});
    var values = rows.map(function(row){return row.map(safeCell);});
    if (body.headers && sheet.getLastRow() === 0) values.unshift(body.headers.map(safeCell));
    var start = sheet.getLastRow() + 1;
    sheet.getRange(start,1,values.length,width).setValues(values);
    SpreadsheetApp.flush();
    return json_({ok:true,sheetUrl:ss.getUrl(),tab:sheet.getName(),appended:rows.length,lastRow:sheet.getLastRow()});
  } catch (_) {
    return json_({ok:false,error:'operation_failed',message:'権限・表ID・GASの実行履歴を確認してください。再送前に表を確認してください。'});
  } finally {
    if (locked) lock.releaseLock();
  }
}
========== END FILE ==========

========== FILE: templates/sheets_append.py ==========
#!/usr/bin/env python3
"""
スプシ連携 — 送信スクリプト
------------------------------------------------------------
クロードコードが、GASの「窓口」Web App にデータを送って
指定スプレッドシートの指定タブに行を追記するためのスクリプト。

認証情報（窓口URL・合言葉トークン）は、事業ハブ直下の .env から読む：
  GAS_SHEETS_URL=https://script.google.com/macros/s/xxxxx/exec
  GAS_SHEETS_TOKEN=合言葉

使い方（コマンドラインから）:
  python sheets_append.py \
    --sheet-id "スプレッドシートのID" \
    --tab "売上ログ" \
    --headers "日付,項目,金額" \
    --rows '[["2026-06-11","売上",1000],["2026-06-11","経費",300]]'

  ※ 実送信には --apply が必要。関数呼び出しは apply=True を指定。
  ※ --tab / --headers は省略可。--headers は空タブのときだけ使われる。

または、他のPythonコードから関数として呼ぶ:
  from sheets_append import append_rows
  append_rows(sheet_id="...", tab="売上ログ",
              headers=["日付","項目","金額"],
              rows=[["2026-06-11","売上",1000]])
"""

import argparse
import json
import os
import re
import sys
import urllib.request
from pathlib import Path

# 名前帳の場所（このファイルと同じディレクトリ）
NAMEBOOK_PATH = Path(__file__).resolve().parent / "スプシ名前帳.md"


def _read_namebook():
    """スプシ名前帳.md のテーブルを {名前: ID} の辞書で返す"""
    if not NAMEBOOK_PATH.exists():
        return {}
    mapping = {}
    for line in NAMEBOOK_PATH.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line.startswith("|"):
            continue
        cells = [c.strip() for c in line.strip("|").split("|")]
        if len(cells) < 2:
            continue
        name, sheet_id = cells[0], cells[1]
        # 見出し行・区切り行をスキップ
        if name in ("名前", "") or re.match(r"^-+$", sheet_id) or "スプシID" in sheet_id:
            continue
        mapping[name] = sheet_id
    return mapping


def resolve_sheet(name_or_id):
    """名前帳に登録があれば名前→IDを返す。なければそのままIDとして扱う。"""
    book = _read_namebook()
    if name_or_id in book:
        return book[name_or_id]
    return name_or_id


def _load_env():
    """事業ハブ直下の .env を上方向に探して読み込む（python-dotenvが無くても動く簡易版）"""
    here = Path(__file__).resolve().parent
    for parent in [here, *here.parents]:
        candidate = parent / ".env"
        if candidate.exists():
            for line in candidate.read_text(encoding="utf-8").splitlines():
                line = line.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                k, v = line.split("=", 1)
                os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
            return


def append_rows(sheet_id, rows, tab=None, headers=None, *, apply=False, create_tab=False, allow_formulas=False):
    """GASの窓口にPOSTして行を追記。結果のdictを返す。"""
    if not isinstance(rows, list) or not rows or not isinstance(rows[0], list) or not rows[0]:
        raise ValueError("rowsは空でない二次元配列にしてください")
    width = len(rows[0])
    if len(rows)>1000 or width>100 or any(not isinstance(r,list) or len(r)!=width for r in rows):
        raise ValueError("列数を統一してください（上限1000行×100列）")
    if any(v is not None and not isinstance(v,(str,int,float,bool)) for r in rows for v in r):
        raise ValueError("セルには文字列・数値・真偽値・nullだけ指定できます")
    json.dumps(rows, allow_nan=False)
    if headers is not None and (not isinstance(headers,list) or len(headers)!=width or not all(isinstance(h,str) for h in headers)):
        raise ValueError("headersは列数と一致する文字列配列にしてください")
    sheet_id = resolve_sheet(sheet_id)
    if not re.fullmatch(r"[A-Za-z0-9_-]+",sheet_id):
        raise ValueError("未登録の名前です。名前帳かスプレッドシートIDを確認してください")
    if not apply:
        return {"preview":True,"sheetId":sheet_id,"tab":tab,"headers":headers,"rows":rows,
                "createTab":create_tab,"allowFormulas":allow_formulas}
    _load_env()
    url = os.environ.get("GAS_SHEETS_URL")
    token = os.environ.get("GAS_SHEETS_TOKEN")
    if not url or not token:
        raise RuntimeError(
            "GAS_SHEETS_URL / GAS_SHEETS_TOKEN が .env に設定されていません。"
        )

    sheet_id = resolve_sheet(sheet_id)
    payload = {"token": token, "sheetId": sheet_id, "rows": rows}
    if tab:
        payload["tab"] = tab
    if headers:
        payload["headers"] = headers

    payload.update(createTab=create_tab, allowFormulas=allow_formulas)
    data = json.dumps(payload, allow_nan=False).encode("utf-8")
    req = urllib.request.Request(
        url, data=data, headers={"Content-Type": "application/json"}
    )
    # GASのWeb Appはリダイレクトを挟むので追従させる
    with urllib.request.urlopen(req, timeout=30) as resp:
        result = json.loads(resp.read().decode("utf-8"))
    return result


def main():
    p = argparse.ArgumentParser(description="GAS経由でスプシに行を追記する")
    p.add_argument("--sheet-id", required=True, help="スプレッドシートのID、または名前帳に登録された名前")
    p.add_argument("--tab", default=None, help="タブ名（省略時は先頭タブ）")
    p.add_argument("--headers", default=None, help="見出しをカンマ区切りで（空タブのときだけ書かれる）")
    source = p.add_mutually_exclusive_group(required=True)
    source.add_argument("--rows", help="行データのJSON配列。例 '[[\"a\",\"b\"]]'")
    source.add_argument("--rows-file", help="UTF-8の行データJSONファイル")
    p.add_argument("--apply", action="store_true", help="実際に送信する（既定はプレビュー）")
    p.add_argument("--create-tab", action="store_true", help="指定した新規タブを作成する")
    p.add_argument("--allow-formulas", action="store_true", help="先頭=を数式として書き込む")
    args = p.parse_args()

    headers = [h.strip() for h in args.headers.split(",")] if args.headers else None
    try:
        rows = json.loads(Path(args.rows_file).read_text(encoding="utf-8") if args.rows_file else args.rows)
    except json.JSONDecodeError as e:
        print(f"--rows のJSONが不正です: {e}", file=sys.stderr)
        sys.exit(1)

    result = append_rows(
        sheet_id=args.sheet_id, rows=rows, tab=args.tab, headers=headers,
        apply=args.apply, create_tab=args.create_tab, allow_formulas=args.allow_formulas
    )
    print(json.dumps(result, ensure_ascii=False, indent=2))
    if not result.get("ok") and not result.get("preview"):
        sys.exit(1)


if __name__ == "__main__":
    try:
        main()
    except (ValueError, OSError, RuntimeError):
        print("処理を完了できませんでした。入力・設定・接続先を確認し、送信後の失敗なら表を確認してから再実行してください。", file=sys.stderr)
        sys.exit(1)
========== END FILE ==========

========== FILE: templates/スプシ名前帳.md ==========
# スプシ名前帳

クロードコード／Codexに「**この名前のスプシに追記して**」と頼むための、スプシ名 ↔ ID の対応表。
ここに登録さえすれば、以降は名前で呼べる。

---

## 使い方

### AIへの指示の仕方
- 「**売上ログ** のスプシに 〇〇 追記して」
- 「**受講生対応** に今日のやり取り記録して」

### 新しいスプシを追加したいとき
2通りある：

1. **AIに頼む（推奨）**
   - 「**◯◯**っていう名前で、このスプシ追加して → 〈スプシのURL〉」
   - → AIが自動でこの表に1行足す
2. **自分で表に書き加える**
   - 下の表に1行追加するだけ

---

## 名前帳

| 名前 | スプシID | URL | 用途メモ |
|---|---|---|---|

---

## ルール

- **名前は一意**にする（同じ名前を2回使わない）
- 名前は短く・口に出しやすく（「売上ログ」「受講生対応」「KPI週次」など）
- このファイルは絶対にIDの列を消さない（消すと呼び出せなくなる）
- スプシ自体を消した時は、この表の行も消す（混乱防止）
========== END FILE ==========

========== FILE: templates/手順.md ==========
# スプシ連携の設定メモ

手順はSKILL.mdを参照。TOKENの実値はこのファイルへ書かず、ローカルの.envとGASスクリプトプロパティだけに保存する。

設定後はテスト用の表に1行追記して確認する。通常の実行はプレビューが既定、送信には --apply が必要。
========== END FILE ==========
